Files
yattee/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift
Arkadiusz Fal 9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00

658 lines
24 KiB
Swift

//
// PlayerControlsSettingsView.swift
// Yattee
//
// Main settings view for player controls customization.
//
import SwiftUI
/// Settings view for customizing player controls layout and presets.
struct PlayerControlsSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
@State private var viewModel: PlayerControlsSettingsViewModel?
var body: some View {
Group {
if let viewModel {
PlayerControlsSettingsContent(viewModel: viewModel)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle(String(localized: "settings.playerControls.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
if viewModel == nil, let appEnv = appEnvironment {
let vm = PlayerControlsSettingsViewModel(
layoutService: appEnv.playerControlsLayoutService,
settingsManager: appEnv.settingsManager
)
viewModel = vm
await vm.loadPresets()
}
}
}
}
// MARK: - Content View
private struct PlayerControlsSettingsContent: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var style: ControlsStyle = .glass
@State private var buttonSize: ButtonSize = .medium
@State private var fontStyle: ControlsFontStyle = .system
// Refresh trigger to force view update on navigation back
@State private var refreshID = UUID()
// Track active preset locally to ensure view updates
@State private var trackedActivePresetName: String = "Default"
// Create preset sheet state (kept at parent level for stability across refreshes)
@State private var showCreatePresetSheet = false
@State private var pendingPresetCreation: PendingPresetCreation?
private struct PendingPresetCreation: Equatable {
let name: String
let basePresetID: UUID?
}
/// Layout with local settings override for immediate preview updates
private var previewLayout: PlayerControlsLayout {
var layout = viewModel.currentLayout
layout.globalSettings.style = style
layout.globalSettings.buttonSize = buttonSize
layout.globalSettings.fontStyle = fontStyle
return layout
}
var body: some View {
Form {
// The phone-shaped preview and comments pill don't apply to the macOS player.
#if os(iOS)
PreviewSection(viewModel: viewModel, layout: previewLayout)
#endif
PresetSection(
viewModel: viewModel,
trackedPresetName: $trackedActivePresetName,
showCreateSheet: $showCreatePresetSheet
)
AppearanceSection(
viewModel: viewModel,
style: $style,
buttonSize: $buttonSize,
fontStyle: $fontStyle
)
LayoutSectionsSection(viewModel: viewModel)
#if os(iOS)
CommentsPillSection(viewModel: viewModel)
GesturesSectionsSection(viewModel: viewModel)
#endif
SystemControlsSection(viewModel: viewModel)
VolumeSection(viewModel: viewModel)
}
#if os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.id(refreshID)
.alert(
String(localized: "settings.playerControls.error"),
isPresented: .constant(viewModel.error != nil)
) {
Button(String(localized: "settings.playerControls.ok")) {
viewModel.clearError()
}
} message: {
if let error = viewModel.error {
Text(error)
}
}
.sheet(isPresented: $showCreatePresetSheet) {
PresetEditorView(
mode: .create(
baseLayouts: viewModel.presets,
activePreset: viewModel.activePreset
),
onSave: { name, basePresetID in
pendingPresetCreation = PendingPresetCreation(name: name, basePresetID: basePresetID)
}
)
}
.task(id: pendingPresetCreation) {
guard let creation = pendingPresetCreation else { return }
let basePreset = creation.basePresetID.flatMap { id in
viewModel.presets.first { $0.id == id }
}
await viewModel.createPreset(name: creation.name, basedOn: basePreset)
pendingPresetCreation = nil
trackedActivePresetName = viewModel.activePreset?.name ?? "Default"
NotificationCenter.default.post(name: .presetSelectionDidChange, object: viewModel.activePreset?.name)
}
.onAppear {
style = viewModel.currentLayout.globalSettings.style
buttonSize = viewModel.currentLayout.globalSettings.buttonSize
fontStyle = viewModel.currentLayout.globalSettings.fontStyle
trackedActivePresetName = viewModel.activePreset?.name ?? "Default"
// Force refresh on every appear to pick up changes from sub-editors
refreshID = UUID()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
// Sync when preset changes
style = viewModel.currentLayout.globalSettings.style
buttonSize = viewModel.currentLayout.globalSettings.buttonSize
fontStyle = viewModel.currentLayout.globalSettings.fontStyle
trackedActivePresetName = viewModel.activePreset?.name ?? "Default"
}
.onChange(of: viewModel.activePreset?.name) { _, newName in
// Also track name changes (for initial load)
trackedActivePresetName = newName ?? "Default"
}
.onReceive(NotificationCenter.default.publisher(for: .presetSelectionDidChange)) { notification in
if let name = notification.object as? String {
trackedActivePresetName = name
}
}
}
}
// MARK: - Preview Section
private struct PreviewSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
let layout: PlayerControlsLayout
var body: some View {
Section {
PlayerControlsPreviewView(
layout: layout,
isLandscape: viewModel.isPreviewingLandscape
)
.frame(height: 200)
.listRowInsets(EdgeInsets())
Picker(
String(localized: "settings.playerControls.previewOrientation"),
selection: $viewModel.isPreviewingLandscape
) {
Text(String(localized: "settings.playerControls.portrait"))
.tag(false)
Text(String(localized: "settings.playerControls.landscape"))
.tag(true)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
}
// MARK: - Preset Section
private struct PresetSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
@Binding var trackedPresetName: String
@Binding var showCreateSheet: Bool
private var isBuiltInPreset: Bool {
viewModel.activePreset?.isBuiltIn == true
}
var body: some View {
Section {
NavigationLink {
PresetSelectorView(viewModel: viewModel, onPresetSelected: { name in
trackedPresetName = name
})
} label: {
HStack {
Text(String(localized: "settings.playerControls.activePreset"))
Spacer()
Text(trackedPresetName)
.foregroundStyle(.secondary)
}
}
if isBuiltInPreset {
Button {
showCreateSheet = true
} label: {
Text(String(localized: "settings.playerControls.newPreset"))
}
}
} footer: {
if isBuiltInPreset {
Text(String(localized: "settings.playerControls.builtInPresetHint"))
}
}
}
}
// MARK: - Appearance Section
private struct AppearanceSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
@Binding var style: ControlsStyle
@Binding var buttonSize: ButtonSize
@Binding var fontStyle: ControlsFontStyle
var body: some View {
Section {
// The macOS bar draws its own glass capsule; the per-button style has no effect there.
#if os(iOS)
Picker(
String(localized: "settings.playerControls.style"),
selection: $style
) {
ForEach(ControlsStyle.allCases, id: \.self) { styleOption in
Text(styleOption.displayName).tag(styleOption)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: style) { _, newStyle in
guard newStyle != viewModel.currentLayout.globalSettings.style else { return }
viewModel.updateGlobalSettingsSync { $0.style = newStyle }
}
#endif
Picker(
String(localized: "settings.playerControls.buttonSize"),
selection: $buttonSize
) {
ForEach(ButtonSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: buttonSize) { _, newSize in
guard newSize != viewModel.currentLayout.globalSettings.buttonSize else { return }
viewModel.updateGlobalSettingsSync { $0.buttonSize = newSize }
}
Picker(
String(localized: "settings.playerControls.fontStyle"),
selection: $fontStyle
) {
ForEach(ControlsFontStyle.allCases, id: \.self) { fontStyleOption in
Text(fontStyleOption.displayName).tag(fontStyleOption)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: fontStyle) { _, newFontStyle in
guard newFontStyle != viewModel.currentLayout.globalSettings.fontStyle else { return }
viewModel.updateGlobalSettingsSync { $0.fontStyle = newFontStyle }
}
} header: {
Text(String(localized: "settings.playerControls.appearance"))
}
}
}
// MARK: - Layout Sections Section
private struct LayoutSectionsSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
var body: some View {
Section {
NavigationLink {
SectionEditorView(
sectionType: .top,
viewModel: viewModel
)
} label: {
HStack {
Label(
String(localized: "settings.playerControls.topButtons"),
systemImage: "rectangle.topthird.inset.filled"
)
Spacer()
Text("\(viewModel.currentLayout.topSection.buttons.count)")
.foregroundStyle(.secondary)
}
}
.disabled(!viewModel.canEditActivePreset)
NavigationLink {
CenterControlsSettingsView(viewModel: viewModel)
} label: {
#if os(macOS)
Label(
String(localized: "settings.playerControls.seekDurations", defaultValue: "Seek Durations"),
systemImage: "arrow.trianglehead.clockwise"
)
#else
Label(
String(localized: "settings.playerControls.centerControls"),
systemImage: "play.circle"
)
#endif
}
.disabled(!viewModel.canEditActivePreset)
NavigationLink {
ProgressBarSettingsView(viewModel: viewModel)
} label: {
Label(
String(localized: "settings.playerControls.progressBar"),
systemImage: "slider.horizontal.below.rectangle"
)
}
.disabled(!viewModel.canEditActivePreset)
NavigationLink {
SectionEditorView(
sectionType: .bottom,
viewModel: viewModel
)
} label: {
HStack {
#if os(macOS)
Label(
String(localized: "settings.playerControls.controlBar", defaultValue: "Control Bar"),
systemImage: "rectangle.bottomthird.inset.filled"
)
#else
Label(
String(localized: "settings.playerControls.bottomButtons"),
systemImage: "rectangle.bottomthird.inset.filled"
)
#endif
Spacer()
Text("\(viewModel.currentLayout.bottomSection.buttons.count)")
.foregroundStyle(.secondary)
}
}
.disabled(!viewModel.canEditActivePreset)
// The player pill only exists in the iOS player.
#if os(iOS)
NavigationLink {
PlayerPillEditorView(viewModel: viewModel)
} label: {
HStack {
Label(
String(localized: "settings.playerControls.playerPill"),
systemImage: "capsule"
)
Spacer()
Text("\(viewModel.playerPillSettings.buttons.count)")
.foregroundStyle(.secondary)
}
}
.disabled(!viewModel.canEditActivePreset)
#endif
NavigationLink {
MiniPlayerEditorView(viewModel: viewModel)
} label: {
Label(
String(localized: "settings.playerControls.miniPlayer"),
systemImage: "pip"
)
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "settings.playerControls.layoutSections"))
} footer: {
if !viewModel.canEditActivePreset {
Text(String(localized: "settings.playerControls.duplicateToEdit"))
}
}
}
}
// MARK: - Comments Pill Section
private struct CommentsPillSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var commentsPillMode: CommentsPillMode = .pill
var body: some View {
Section {
Picker(
String(localized: "commentsPill.mode.title"),
selection: $commentsPillMode
) {
ForEach(CommentsPillMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: commentsPillMode) { _, newValue in
guard newValue != viewModel.commentsPillMode else { return }
viewModel.syncCommentsPillMode(newValue)
}
}
.onAppear {
commentsPillMode = viewModel.commentsPillMode
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
commentsPillMode = viewModel.commentsPillMode
}
}
}
// MARK: - Gestures Sections Section (iOS only)
#if os(iOS)
private struct GesturesSectionsSection: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
var body: some View {
Section {
NavigationLink {
TapGesturesSettingsView(viewModel: viewModel)
} label: {
HStack {
Label(
String(localized: "gestures.tap.title", defaultValue: "Tap Gestures"),
systemImage: "hand.tap"
)
Spacer()
if viewModel.gesturesSettings.tapGestures.isEnabled {
Text(viewModel.gesturesSettings.tapGestures.layout.layoutDescription)
.foregroundStyle(.secondary)
} else {
Text(String(localized: "common.disabled", defaultValue: "Disabled"))
.foregroundStyle(.tertiary)
}
}
}
.disabled(!viewModel.canEditActivePreset)
NavigationLink {
SeekGestureSettingsView(viewModel: viewModel)
} label: {
HStack {
Label(
String(localized: "gestures.seek.title", defaultValue: "Seek Gesture"),
systemImage: "hand.draw"
)
Spacer()
if viewModel.gesturesSettings.seekGesture.isEnabled {
Text(viewModel.gesturesSettings.seekGesture.sensitivity.displayName)
.foregroundStyle(.secondary)
} else {
Text(String(localized: "common.disabled", defaultValue: "Disabled"))
.foregroundStyle(.tertiary)
}
}
}
.disabled(!viewModel.canEditActivePreset)
NavigationLink {
PanscanGestureSettingsView(viewModel: viewModel)
} label: {
HStack {
Label(
String(localized: "gestures.panscan.title", defaultValue: "Panscan Gesture"),
systemImage: "hand.pinch"
)
Spacer()
if viewModel.gesturesSettings.panscanGesture.isEnabled {
if viewModel.gesturesSettings.panscanGesture.snapToEnds {
Text(String(localized: "gestures.panscan.snap", defaultValue: "Snap"))
.foregroundStyle(.secondary)
} else {
Text(String(localized: "gestures.panscan.freeZoom", defaultValue: "Free Zoom"))
.foregroundStyle(.secondary)
}
} else {
Text(String(localized: "common.disabled", defaultValue: "Disabled"))
.foregroundStyle(.tertiary)
}
}
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "gestures.section.title", defaultValue: "Gestures"))
} footer: {
Text(String(localized: "gestures.section.footer", defaultValue: "Control playback with gestures when player controls are hidden."))
}
}
}
#endif
// MARK: - System Controls Section
private struct SystemControlsSection: View {
@Environment(\.appEnvironment) private var appEnvironment
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var systemControlsMode: SystemControlsMode = .seek
@State private var systemControlsSeekDuration: SystemControlsSeekDuration = .tenSeconds
var body: some View {
Section {
Picker(
String(localized: "settings.playback.systemControls.mode"),
selection: $systemControlsMode
) {
ForEach(SystemControlsMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: systemControlsMode) { _, newMode in
guard newMode != viewModel.systemControlsMode else { return }
viewModel.updateSystemControlsModeSync(newMode)
appEnvironment?.playerService.reconfigureSystemControls(
mode: newMode,
duration: viewModel.systemControlsSeekDuration
)
}
if systemControlsMode == .seek {
Picker(
String(localized: "settings.playback.systemControls.seekDuration"),
selection: $systemControlsSeekDuration
) {
ForEach(SystemControlsSeekDuration.allCases, id: \.self) { duration in
Text(duration.displayName).tag(duration)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: systemControlsSeekDuration) { _, newDuration in
guard newDuration != viewModel.systemControlsSeekDuration else { return }
viewModel.updateSystemControlsSeekDurationSync(newDuration)
appEnvironment?.playerService.reconfigureSystemControls(
mode: viewModel.systemControlsMode,
duration: newDuration
)
}
}
} header: {
Text(String(localized: "settings.playback.systemControls.header"))
} footer: {
if systemControlsMode == .seek {
Text(String(localized: "settings.playback.systemControls.seek.footer"))
} else {
Text(String(localized: "settings.playback.systemControls.skipTrack.footer"))
}
}
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
private func syncLocalState() {
systemControlsMode = viewModel.systemControlsMode
systemControlsSeekDuration = viewModel.systemControlsSeekDuration
}
}
// MARK: - Volume Section
private struct VolumeSection: View {
@Environment(\.appEnvironment) private var appEnvironment
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var volumeMode: VolumeMode = .mpv
var body: some View {
Section {
Picker(
String(localized: "settings.playback.volume.mode"),
selection: $volumeMode
) {
ForEach(VolumeMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: volumeMode) { _, newMode in
guard newMode != viewModel.volumeMode else { return }
viewModel.updateVolumeModeSync(newMode)
if newMode == .system {
// Set MPV to 100% when switching to system mode
appEnvironment?.playerService.currentBackend?.volume = 1.0
appEnvironment?.playerService.state.volume = 1.0
}
// Broadcast the mode change to remote devices
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
}
} header: {
Text(String(localized: "settings.playback.volume.header"))
} footer: {
if volumeMode == .mpv {
Text(String(localized: "settings.playback.volume.mpv.footer"))
} else {
Text(String(localized: "settings.playback.volume.system.footer"))
}
}
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
private func syncLocalState() {
volumeMode = viewModel.volumeMode
}
}
// MARK: - Preview
#Preview {
NavigationStack {
PlayerControlsSettingsView()
}
.appEnvironment(.preview)
}