mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The pill mode now lives on CommentsPageSettings and is edited in the comments page editor instead of a standalone settings section. The legacy value on PlayerPillSettings is kept for decoding old presets and migrated by effectivePanelSettings. Comments are loaded when either the comments page is enabled or the pill is not disabled.
642 lines
24 KiB
Swift
642 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)
|
|
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)
|
|
|
|
// The details panel exists in the iOS and macOS players.
|
|
#if os(iOS) || os(macOS)
|
|
NavigationLink {
|
|
PanelSettingsView(viewModel: viewModel)
|
|
} label: {
|
|
HStack {
|
|
Label(
|
|
String(localized: "settings.playerControls.detailsPanel"),
|
|
systemImage: "sidebar.trailing"
|
|
)
|
|
Spacer()
|
|
Text("\(viewModel.enabledPanelPageCount)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "settings.playerControls.layoutSections"))
|
|
} footer: {
|
|
if !viewModel.canEditActivePreset {
|
|
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|