Files
yattee/Yattee/Views/Settings/PlayerControls/PlayerControlsSettingsView.swift

631 lines
23 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 {
PreviewSection(viewModel: viewModel, layout: previewLayout)
PresetSection(
viewModel: viewModel,
trackedPresetName: $trackedActivePresetName,
showCreateSheet: $showCreatePresetSheet
)
AppearanceSection(
viewModel: viewModel,
style: $style,
buttonSize: $buttonSize,
fontStyle: $fontStyle
)
LayoutSectionsSection(viewModel: viewModel)
CommentsPillSection(viewModel: viewModel)
#if os(iOS)
GesturesSectionsSection(viewModel: viewModel)
#endif
SystemControlsSection(viewModel: viewModel)
VolumeSection(viewModel: viewModel)
}
.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 {
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 }
}
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: {
Label(
String(localized: "settings.playerControls.centerControls"),
systemImage: "play.circle"
)
}
.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 {
Label(
String(localized: "settings.playerControls.bottomButtons"),
systemImage: "rectangle.bottomthird.inset.filled"
)
Spacer()
Text("\(viewModel.currentLayout.bottomSection.buttons.count)")
.foregroundStyle(.secondary)
}
}
.disabled(!viewModel.canEditActivePreset)
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)
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)
}