mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
631 lines
23 KiB
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)
|
|
}
|