Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,382 @@
//
// ButtonConfigurationView.swift
// Yattee
//
// View for configuring individual button settings.
//
import SwiftUI
/// View for configuring a single button's settings.
struct ButtonConfigurationView: View {
let buttonID: UUID
let sectionType: LayoutSectionType
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var visibilityMode: VisibilityMode = .both
@State private var spacerIsFlexible: Bool = true
@State private var spacerWidth: Double = 20
@State private var sliderBehavior: SliderBehavior = .expandOnTap
@State private var seekSeconds: Double = 10
@State private var seekDirection: SeekDirection = .forward
@State private var timeDisplayFormat: TimeDisplayFormat = .currentAndTotal
@State private var titleAuthorShowSourceImage: Bool = true
@State private var titleAuthorShowTitle: Bool = true
@State private var titleAuthorShowSourceName: Bool = true
/// Look up the current configuration from the view model's layout.
private var configuration: ControlButtonConfiguration? {
let section = sectionType == .top
? viewModel.currentLayout.topSection
: viewModel.currentLayout.bottomSection
return section.buttons.first { $0.id == buttonID }
}
var body: some View {
if let config = configuration {
Form {
// Visibility mode (all buttons)
Section {
Picker(
String(localized: "settings.playerControls.visibility"),
selection: $visibilityMode
) {
ForEach(VisibilityMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.onChange(of: visibilityMode) { _, newValue in
updateVisibility(newValue)
}
} header: {
Text(String(localized: "settings.playerControls.visibilityHeader"))
} footer: {
Text(String(localized: "settings.playerControls.visibilityFooter"))
}
// Type-specific settings
if config.buttonType.hasSettings {
typeSpecificSettings(for: config)
}
}
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
}
}
// MARK: - Sync from Configuration
private func syncFromConfiguration(_ config: ControlButtonConfiguration) {
visibilityMode = config.visibilityMode
switch config.settings {
case .spacer(let settings):
spacerIsFlexible = settings.isFlexible
spacerWidth = Double(settings.fixedWidth)
case .slider(let settings):
sliderBehavior = settings.sliderBehavior
case .seek(let settings):
seekSeconds = Double(settings.seconds)
seekDirection = settings.direction
case .timeDisplay(let settings):
timeDisplayFormat = settings.format
case .titleAuthor(let settings):
titleAuthorShowSourceImage = settings.showSourceImage
titleAuthorShowTitle = settings.showTitle
titleAuthorShowSourceName = settings.showSourceName
case .none:
break
}
}
// MARK: - Type-Specific Settings
@ViewBuilder
private func typeSpecificSettings(for config: ControlButtonConfiguration) -> some View {
switch config.buttonType {
case .spacer:
spacerSettings
case .brightness, .volume:
sliderSettings
case .seekBackward, .seekForward:
seekSettings
case .seek:
seekSettingsForHorizontal
case .timeDisplay:
timeDisplaySettings
case .titleAuthor:
titleAuthorSettings
default:
EmptyView()
}
}
// MARK: - Spacer Settings
@ViewBuilder
private var spacerSettings: some View {
Section {
Toggle(
String(localized: "settings.playerControls.spacer.flexible"),
isOn: $spacerIsFlexible
)
.onChange(of: spacerIsFlexible) { _, newValue in
updateSpacerSettings(isFlexible: newValue, width: Int(spacerWidth))
}
#if !os(tvOS)
if !spacerIsFlexible {
HStack {
Text(String(localized: "settings.playerControls.spacer.width"))
Spacer()
Text("\(Int(spacerWidth))pt")
.foregroundStyle(.secondary)
}
Slider(
value: $spacerWidth,
in: 4...100,
step: 2
)
.onChange(of: spacerWidth) { _, newValue in
updateSpacerSettings(isFlexible: spacerIsFlexible, width: Int(newValue))
}
}
#endif
} header: {
Text(String(localized: "settings.playerControls.spacer.header"))
} footer: {
Text(String(localized: "settings.playerControls.spacer.footer"))
}
}
// MARK: - Slider Settings (Brightness/Volume)
@ViewBuilder
private var sliderSettings: some View {
Section {
Picker(
String(localized: "settings.playerControls.slider.behavior"),
selection: $sliderBehavior
) {
ForEach(SliderBehavior.allCases, id: \.self) { behavior in
Text(behavior.displayName).tag(behavior)
}
}
.onChange(of: sliderBehavior) { _, newValue in
updateSliderSettings(behavior: newValue)
}
} header: {
Text(String(localized: "settings.playerControls.slider.header"))
} footer: {
Text(String(localized: "settings.playerControls.slider.footer"))
}
}
private func updateSliderSettings(behavior: SliderBehavior) {
let settings = SliderSettings(sliderBehavior: behavior)
updateSettings(.slider(settings))
}
// MARK: - Seek Settings
@ViewBuilder
private var seekSettings: some View {
Section {
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.seek.seconds"))
Spacer()
Text("\(Int(seekSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekSeconds,
in: 1...60,
step: 1
)
.onChange(of: seekSeconds) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(newValue), direction: seekDirection)))
}
#endif
// Quick presets
HStack {
ForEach([5, 10, 15, 30], id: \.self) { preset in
Button("\(preset)s") {
seekSeconds = Double(preset)
}
.buttonStyle(.bordered)
.tint(Int(seekSeconds) == preset ? .accentColor : .secondary)
}
}
} header: {
Text(String(localized: "settings.playerControls.seek.header"))
}
}
// MARK: - Seek Settings for Horizontal Sections
@ViewBuilder
private var seekSettingsForHorizontal: some View {
Section {
// Direction picker
Picker(
String(localized: "settings.playerControls.seek.direction"),
selection: $seekDirection
) {
ForEach(SeekDirection.allCases, id: \.self) { direction in
Text(direction.displayName).tag(direction)
}
}
.onChange(of: seekDirection) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(seekSeconds), direction: newValue)))
}
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.seek.seconds"))
Spacer()
Text("\(Int(seekSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekSeconds,
in: 1...60,
step: 1
)
.onChange(of: seekSeconds) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(newValue), direction: seekDirection)))
}
#endif
// Quick presets
HStack {
ForEach([5, 10, 15, 30], id: \.self) { preset in
Button("\(preset)s") {
seekSeconds = Double(preset)
updateSettings(.seek(SeekSettings(seconds: preset, direction: seekDirection)))
}
.buttonStyle(.bordered)
.tint(Int(seekSeconds) == preset ? .accentColor : .secondary)
}
}
} header: {
Text(String(localized: "settings.playerControls.seek.header"))
}
}
// MARK: - Time Display Settings
@ViewBuilder
private var timeDisplaySettings: some View {
Section {
Picker(
String(localized: "settings.playerControls.timeDisplay.format"),
selection: $timeDisplayFormat
) {
ForEach(TimeDisplayFormat.allCases, id: \.self) { format in
Text(format.displayName).tag(format)
}
}
.onChange(of: timeDisplayFormat) { _, newValue in
updateSettings(.timeDisplay(TimeDisplaySettings(format: newValue)))
}
} header: {
Text(String(localized: "settings.playerControls.timeDisplay.header"))
} footer: {
Text(String(localized: "settings.playerControls.timeDisplay.footer"))
}
}
// MARK: - Title/Author Settings
@ViewBuilder
private var titleAuthorSettings: some View {
Section {
Toggle(
String(localized: "settings.playerControls.titleAuthor.showSourceImage"),
isOn: $titleAuthorShowSourceImage
)
.onChange(of: titleAuthorShowSourceImage) { _, _ in
updateTitleAuthorSettings()
}
Toggle(
String(localized: "settings.playerControls.titleAuthor.showTitle"),
isOn: $titleAuthorShowTitle
)
.onChange(of: titleAuthorShowTitle) { _, _ in
updateTitleAuthorSettings()
}
Toggle(
String(localized: "settings.playerControls.titleAuthor.showSourceName"),
isOn: $titleAuthorShowSourceName
)
.onChange(of: titleAuthorShowSourceName) { _, _ in
updateTitleAuthorSettings()
}
} header: {
Text(String(localized: "settings.playerControls.titleAuthor.header"))
} footer: {
Text(String(localized: "settings.playerControls.titleAuthor.footer"))
}
}
private func updateTitleAuthorSettings() {
let settings = TitleAuthorSettings(
showSourceImage: titleAuthorShowSourceImage,
showTitle: titleAuthorShowTitle,
showSourceName: titleAuthorShowSourceName
)
updateSettings(.titleAuthor(settings))
}
// MARK: - Update Helpers
private func updateVisibility(_ mode: VisibilityMode) {
guard var updated = configuration else { return }
updated.visibilityMode = mode
viewModel.updateButtonConfigurationSync(updated, in: sectionType)
}
private func updateSpacerSettings(isFlexible: Bool, width: Int) {
let settings = SpacerSettings(isFlexible: isFlexible, fixedWidth: width)
updateSettings(.spacer(settings))
}
private func updateSettings(_ settings: ButtonSettings) {
guard var updated = configuration else { return }
updated.settings = settings
viewModel.updateButtonConfigurationSync(updated, in: sectionType)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ButtonConfigurationView(
buttonID: UUID(),
sectionType: .top,
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}

View File

@@ -0,0 +1,373 @@
//
// CenterControlsSettingsView.swift
// Yattee
//
// View for configuring center section player controls.
//
import SwiftUI
/// View for configuring center section controls (play/pause, seek buttons).
struct CenterControlsSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state that mirrors ViewModel - ensures immediate UI updates
@State private var showPlayPause: Bool = true
@State private var showSeekBackward: Bool = true
@State private var showSeekForward: Bool = true
@State private var seekBackwardSeconds: Double = 10
@State private var seekForwardSeconds: Double = 10
@State private var leftSlider: SideSliderType = .disabled
@State private var rightSlider: SideSliderType = .disabled
var body: some View {
Form {
// Preview
Section {
CenterPreviewView(
settings: previewSettings,
buttonBackground: viewModel.currentLayout.globalSettings.buttonBackground,
theme: viewModel.currentLayout.globalSettings.theme
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
} header: {
Text(String(localized: "settings.playerControls.preview"))
}
// Play/Pause toggle
Section {
Toggle(
String(localized: "settings.playerControls.center.showPlayPause"),
isOn: $showPlayPause
)
.onChange(of: showPlayPause) { _, newValue in
viewModel.updateCenterSettingsSync { $0.showPlayPause = newValue }
}
} header: {
Text(String(localized: "settings.playerControls.center.playback"))
}
// Seek backward settings
Section {
Toggle(
String(localized: "settings.playerControls.center.showSeekBackward"),
isOn: $showSeekBackward
)
.onChange(of: showSeekBackward) { _, newValue in
viewModel.updateCenterSettingsSync { $0.showSeekBackward = newValue }
}
if showSeekBackward {
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.center.seekBackwardTime"))
Spacer()
Text("\(Int(seekBackwardSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekBackwardSeconds,
in: 1...90,
step: 1
)
.onChange(of: seekBackwardSeconds) { _, newValue in
viewModel.updateCenterSettingsSync { $0.seekBackwardSeconds = Int(newValue) }
}
#endif
// Quick presets
HStack(spacing: 8) {
ForEach([5, 10, 15, 30, 45, 60], id: \.self) { seconds in
Button("\(seconds)s") {
seekBackwardSeconds = Double(seconds)
}
.buttonStyle(.bordered)
#if !os(tvOS)
.controlSize(.small)
#endif
.tint(Int(seekBackwardSeconds) == seconds ? .accentColor : .secondary)
}
}
}
} header: {
Text(String(localized: "settings.playerControls.center.seekBackward"))
}
// Seek forward settings
Section {
Toggle(
String(localized: "settings.playerControls.center.showSeekForward"),
isOn: $showSeekForward
)
.onChange(of: showSeekForward) { _, newValue in
viewModel.updateCenterSettingsSync { $0.showSeekForward = newValue }
}
if showSeekForward {
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.center.seekForwardTime"))
Spacer()
Text("\(Int(seekForwardSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekForwardSeconds,
in: 1...90,
step: 1
)
.onChange(of: seekForwardSeconds) { _, newValue in
viewModel.updateCenterSettingsSync { $0.seekForwardSeconds = Int(newValue) }
}
#endif
// Quick presets
HStack(spacing: 8) {
ForEach([5, 10, 15, 30, 45, 60], id: \.self) { seconds in
Button("\(seconds)s") {
seekForwardSeconds = Double(seconds)
}
.buttonStyle(.bordered)
#if !os(tvOS)
.controlSize(.small)
#endif
.tint(Int(seekForwardSeconds) == seconds ? .accentColor : .secondary)
}
}
}
} header: {
Text(String(localized: "settings.playerControls.center.seekForward"))
}
#if os(iOS)
// Side sliders section (iOS only)
Section {
Picker(
String(localized: "settings.playerControls.center.leftSlider"),
selection: $leftSlider
) {
ForEach(SideSliderType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
.onChange(of: leftSlider) { _, newValue in
viewModel.updateCenterSettingsSync { $0.leftSlider = newValue }
}
Picker(
String(localized: "settings.playerControls.center.rightSlider"),
selection: $rightSlider
) {
ForEach(SideSliderType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
.onChange(of: rightSlider) { _, newValue in
viewModel.updateCenterSettingsSync { $0.rightSlider = newValue }
}
} header: {
Text(String(localized: "settings.playerControls.center.sliders"))
} footer: {
Text(String(localized: "settings.playerControls.center.slidersFooter"))
}
#endif
}
.navigationTitle(String(localized: "settings.playerControls.centerControls"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromViewModel()
}
}
// MARK: - Computed Properties
/// Settings for preview - uses local state for immediate updates
private var previewSettings: CenterSectionSettings {
CenterSectionSettings(
showPlayPause: showPlayPause,
showSeekBackward: showSeekBackward,
showSeekForward: showSeekForward,
seekBackwardSeconds: Int(seekBackwardSeconds),
seekForwardSeconds: Int(seekForwardSeconds),
leftSlider: leftSlider,
rightSlider: rightSlider
)
}
// MARK: - Helpers
/// Syncs local state from ViewModel
private func syncFromViewModel() {
let settings = viewModel.centerSettings
showPlayPause = settings.showPlayPause
showSeekBackward = settings.showSeekBackward
showSeekForward = settings.showSeekForward
seekBackwardSeconds = Double(settings.seekBackwardSeconds)
seekForwardSeconds = Double(settings.seekForwardSeconds)
leftSlider = settings.leftSlider
rightSlider = settings.rightSlider
}
}
// MARK: - Center Preview
#if os(iOS)
/// Preview representation of a vertical side slider.
private struct SliderPreview: View {
let type: SideSliderType
let buttonBackground: ButtonBackgroundStyle
var body: some View {
VStack(spacing: 4) {
if let icon = type.systemImage {
Image(systemName: icon)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white)
}
// Slider track preview
ZStack(alignment: .bottom) {
Capsule()
.fill(.white.opacity(0.3))
Capsule()
.fill(.white)
.frame(height: 30) // ~50% fill for preview
}
.frame(width: 3, height: 60)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.frame(width: 24)
.modifier(SliderPreviewBackgroundModifier(buttonBackground: buttonBackground))
}
}
/// Applies background to slider preview based on button background style.
private struct SliderPreviewBackgroundModifier: ViewModifier {
let buttonBackground: ButtonBackgroundStyle
func body(content: Content) -> some View {
if let glassStyle = buttonBackground.glassStyle {
content.glassBackground(glassStyle, in: .capsule, fallback: .ultraThinMaterial)
} else {
content.background(.ultraThinMaterial.opacity(0.5), in: Capsule())
}
}
}
#endif
private struct CenterPreviewView: View {
let settings: CenterSectionSettings
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
private let buttonSize: CGFloat = 32
private let playButtonSize: CGFloat = 44
private let spacing: CGFloat = 24
var body: some View {
ZStack {
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
// Gradient shade like actual player
LinearGradient(
colors: [.black.opacity(0.7), .clear, .black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
// Center controls
HStack(spacing: spacing) {
if settings.showSeekBackward {
CenterButtonPreview(
systemImage: settings.seekBackwardSystemImage,
size: buttonSize,
buttonBackground: buttonBackground
)
}
if settings.showPlayPause {
CenterButtonPreview(
systemImage: "play.fill",
size: playButtonSize,
buttonBackground: buttonBackground
)
}
if settings.showSeekForward {
CenterButtonPreview(
systemImage: settings.seekForwardSystemImage,
size: buttonSize,
buttonBackground: buttonBackground
)
}
}
#if os(iOS)
// Side sliders preview
HStack {
if settings.leftSlider != .disabled {
SliderPreview(type: settings.leftSlider, buttonBackground: buttonBackground)
.padding(.leading, 8)
}
Spacer()
if settings.rightSlider != .disabled {
SliderPreview(type: settings.rightSlider, buttonBackground: buttonBackground)
.padding(.trailing, 8)
}
}
#endif
}
.frame(height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
.padding(.vertical, 8)
.modifier(PreviewThemeModifier(theme: theme))
}
}
private struct CenterButtonPreview: View {
let systemImage: String
let size: CGFloat
let buttonBackground: ButtonBackgroundStyle
/// Frame size - slightly larger when backgrounds are enabled.
private var frameSize: CGFloat {
buttonBackground.glassStyle != nil ? size * 1.4 : size * 1.2
}
var body: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: systemImage)
.font(.system(size: size * 0.7))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: systemImage)
.font(.system(size: size * 0.7))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
CenterControlsSettingsView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}

View File

@@ -0,0 +1,36 @@
//
// CompactPreviewButtonView.swift
// Yattee
//
// Simplified button preview for pill and mini player editors.
// Renders icon buttons without complex features like sliders, title/author, or spacers.
//
import SwiftUI
struct CompactPreviewButtonView: View {
let configuration: ControlButtonConfiguration
let size: CGFloat
var body: some View {
Image(systemName: iconName)
.font(.system(size: iconSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: size, height: size)
}
// MARK: - Private
/// Icon name, using seek settings for seek buttons
private var iconName: String {
if configuration.buttonType == .seek, let seekSettings = configuration.seekSettings {
return seekSettings.systemImage
}
return configuration.buttonType.systemImage
}
/// Play/pause is slightly larger than other buttons
private var iconSize: CGFloat {
configuration.buttonType == .playPause ? size * 0.9 : size * 0.7
}
}

View File

@@ -0,0 +1,97 @@
//
// PanscanGestureSettingsView.swift
// Yattee
//
// Settings view for configuring pinch-to-panscan gesture.
//
#if os(iOS)
import SwiftUI
/// Settings view for configuring pinch-to-panscan gesture on the player.
struct PanscanGestureSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var isEnabled: Bool = true
@State private var snapToEnds: Bool = true
var body: some View {
List {
enableSection
if isEnabled {
snapModeSection
}
}
.navigationTitle(String(localized: "gestures.panscan.title", defaultValue: "Panscan Gesture"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
syncFromViewModel()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncFromViewModel()
}
}
// MARK: - Sections
private var enableSection: some View {
Section {
Toggle(
String(localized: "gestures.panscan.enable", defaultValue: "Enable Panscan Gesture"),
isOn: $isEnabled
)
.onChange(of: isEnabled) { _, newValue in
viewModel.updatePanscanGestureSettingsSync { $0.isEnabled = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} footer: {
Text(String(localized: "gestures.panscan.enableFooter", defaultValue: "Pinch to zoom between fit and fill modes while in fullscreen."))
}
}
private var snapModeSection: some View {
Section {
Toggle(
String(localized: "gestures.panscan.snapToEnds", defaultValue: "Snap to Fit/Fill"),
isOn: $snapToEnds
)
.onChange(of: snapToEnds) { _, newValue in
viewModel.updatePanscanGestureSettingsSync { $0.snapToEnds = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} footer: {
Text(snapModeFooterText)
}
}
// MARK: - Helpers
private var snapModeFooterText: String {
if snapToEnds {
String(localized: "gestures.panscan.snapToEnds.on.footer", defaultValue: "When released, the zoom will snap to either fit (show full video) or fill (fill the screen).")
} else {
String(localized: "gestures.panscan.snapToEnds.off.footer", defaultValue: "The zoom level stays exactly where you release, allowing any value between fit and fill.")
}
}
private func syncFromViewModel() {
let settings = viewModel.panscanGestureSettings
isEnabled = settings.isEnabled
snapToEnds = settings.snapToEnds
}
}
// MARK: - Preview
#Preview {
NavigationStack {
PanscanGestureSettingsView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}
#endif

View File

@@ -0,0 +1,111 @@
//
// SeekGestureSettingsView.swift
// Yattee
//
// Settings view for configuring horizontal seek gesture.
//
#if os(iOS)
import SwiftUI
/// Settings view for configuring horizontal seek gesture on the player.
struct SeekGestureSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var isEnabled: Bool = false
@State private var sensitivity: SeekGestureSensitivity = .medium
var body: some View {
List {
enableSection
if isEnabled {
sensitivitySection
}
}
.navigationTitle(String(localized: "gestures.seek.title", defaultValue: "Seek Gesture"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
syncFromViewModel()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncFromViewModel()
}
}
// MARK: - Sections
private var enableSection: some View {
Section {
Toggle(
String(localized: "gestures.seek.enable", defaultValue: "Enable Seek Gesture"),
isOn: $isEnabled
)
.onChange(of: isEnabled) { _, newValue in
viewModel.updateSeekGestureSettingsSync { $0.isEnabled = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} footer: {
Text(String(localized: "gestures.seek.enableFooter", defaultValue: "Drag left or right to seek backward or forward when controls are hidden."))
}
}
private var sensitivitySection: some View {
Section {
Picker(
String(localized: "gestures.seek.sensitivity", defaultValue: "Sensitivity"),
selection: $sensitivity
) {
ForEach(SeekGestureSensitivity.allCases, id: \.self) { level in
VStack(alignment: .leading) {
Text(level.displayName)
}
.tag(level)
}
}
.pickerStyle(.inline)
.labelsHidden()
.onChange(of: sensitivity) { _, newValue in
viewModel.updateSeekGestureSettingsSync { $0.sensitivity = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "gestures.seek.sensitivity", defaultValue: "Sensitivity"))
} footer: {
Text(sensitivityFooterText)
}
}
// MARK: - Helpers
private var sensitivityFooterText: String {
switch sensitivity {
case .low:
String(localized: "gestures.seek.sensitivity.low.footer", defaultValue: "Precise control for short videos or fine-tuning.")
case .medium:
String(localized: "gestures.seek.sensitivity.medium.footer", defaultValue: "Balanced for most video lengths.")
case .high:
String(localized: "gestures.seek.sensitivity.high.footer", defaultValue: "Fast navigation for long videos or podcasts.")
}
}
private func syncFromViewModel() {
let settings = viewModel.seekGestureSettings
isEnabled = settings.isEnabled
sensitivity = settings.sensitivity
}
}
// MARK: - Preview
#Preview {
NavigationStack {
SeekGestureSettingsView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}
#endif

View File

@@ -0,0 +1,243 @@
//
// TapGesturesSettingsView.swift
// Yattee
//
// Settings view for configuring tap gestures.
//
#if os(iOS)
import SwiftUI
/// Settings view for configuring tap gestures on the player.
struct TapGesturesSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var isEnabled: Bool = false
@State private var layout: TapZoneLayout = .horizontalSplit
@State private var doubleTapInterval: Int = 300
@State private var zoneConfigurations: [TapZoneConfiguration] = []
// Navigation state
@State private var selectedZonePosition: TapZonePosition?
var body: some View {
List {
enableSection
if isEnabled {
layoutSection
previewSection
zonesSection
timingSection
}
}
.navigationTitle(String(localized: "gestures.tap.title", defaultValue: "Tap Gestures"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromViewModel()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncFromViewModel()
}
.sheet(item: $selectedZonePosition) { position in
NavigationStack {
zoneActionPicker(for: position)
}
}
}
// MARK: - Sections
private var enableSection: some View {
Section {
Toggle(
String(localized: "gestures.tap.enable", defaultValue: "Enable Tap Gestures"),
isOn: $isEnabled
)
.onChange(of: isEnabled) { _, newValue in
viewModel.updateTapGesturesSettingsSync { $0.isEnabled = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} footer: {
Text(String(localized: "gestures.tap.enableFooter", defaultValue: "Double-tap zones on the player to trigger actions when controls are hidden."))
}
}
private var layoutSection: some View {
Section {
TapZoneLayoutPicker(selectedLayout: $layout)
.onChange(of: layout) { _, newLayout in
// Update configurations for new layout
zoneConfigurations = TapGesturesSettings.defaultConfigurations(for: newLayout)
viewModel.updateTapGesturesSettingsSync { settings in
settings = settings.withLayout(newLayout)
}
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "gestures.tap.layout", defaultValue: "Zone Layout"))
} footer: {
Text(String(localized: "gestures.tap.layoutFooter", defaultValue: "Choose how the screen is divided into tap zones."))
}
}
private var previewSection: some View {
Section {
TapZonePreview(
layout: layout,
configurations: zoneConfigurations,
onZoneTapped: { position in
if viewModel.canEditActivePreset {
selectedZonePosition = position
}
}
)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
} header: {
Text(String(localized: "gestures.tap.preview", defaultValue: "Preview"))
} footer: {
Text(String(localized: "gestures.tap.previewFooter", defaultValue: "Tap a zone to configure its action."))
}
}
private var zonesSection: some View {
Section {
ForEach(layout.positions, id: \.self) { position in
Button {
if viewModel.canEditActivePreset {
selectedZonePosition = position
}
} label: {
HStack {
Text(position.displayName)
.foregroundStyle(.primary)
Spacer()
if let config = zoneConfigurations.first(where: { $0.position == position }) {
Label {
Text(actionSummary(for: config.action))
} icon: {
Image(systemName: config.action.systemImage)
}
.foregroundStyle(.secondary)
.font(.subheadline)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.disabled(!viewModel.canEditActivePreset)
}
} header: {
Text(String(localized: "gestures.tap.zones", defaultValue: "Zone Actions"))
}
}
private var timingSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(String(localized: "gestures.tap.doubleTapWindow", defaultValue: "Double-Tap Window"))
Spacer()
Text("\(doubleTapInterval)ms")
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(
value: Binding(
get: { Double(doubleTapInterval) },
set: {
doubleTapInterval = Int($0)
viewModel.updateTapGesturesSettingsSync { $0.doubleTapInterval = doubleTapInterval }
}
),
in: Double(TapGesturesSettings.doubleTapIntervalRange.lowerBound)...Double(TapGesturesSettings.doubleTapIntervalRange.upperBound),
step: 25
)
.disabled(!viewModel.canEditActivePreset)
}
} header: {
Text(String(localized: "gestures.tap.timing", defaultValue: "Timing"))
} footer: {
Text(String(localized: "gestures.tap.timingFooter", defaultValue: "Time window to detect a double-tap. Lower values are faster but may conflict with single-tap."))
}
}
// MARK: - Helpers
@ViewBuilder
private func zoneActionPicker(for position: TapZonePosition) -> some View {
let binding = Binding<TapGestureAction>(
get: {
zoneConfigurations.first { $0.position == position }?.action ?? .togglePlayPause
},
set: { newAction in
if let index = zoneConfigurations.firstIndex(where: { $0.position == position }) {
zoneConfigurations[index] = zoneConfigurations[index].withAction(newAction)
viewModel.updateTapZoneConfigurationSync(zoneConfigurations[index])
}
}
)
TapZoneActionPicker(position: position, action: binding)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(role: .cancel) {
selectedZonePosition = nil
} label: {
Label("Close", systemImage: "xmark")
.labelStyle(.iconOnly)
}
}
}
}
private func syncFromViewModel() {
let settings = viewModel.gesturesSettings.tapGestures
isEnabled = settings.isEnabled
layout = settings.layout
doubleTapInterval = settings.doubleTapInterval
zoneConfigurations = settings.zoneConfigurations
}
private func actionSummary(for action: TapGestureAction) -> String {
switch action {
case .seekForward(let seconds):
"+\(seconds)s"
case .seekBackward(let seconds):
"-\(seconds)s"
case .togglePlayPause:
String(localized: "gestures.action.playPause.short", defaultValue: "Play/Pause")
case .toggleFullscreen:
String(localized: "gestures.action.fullscreen.short", defaultValue: "Fullscreen")
case .togglePiP:
String(localized: "gestures.action.pip.short", defaultValue: "PiP")
case .playNext:
String(localized: "gestures.action.next.short", defaultValue: "Next")
case .playPrevious:
String(localized: "gestures.action.previous.short", defaultValue: "Previous")
case .cyclePlaybackSpeed:
String(localized: "gestures.action.speed.short", defaultValue: "Speed")
case .toggleMute:
String(localized: "gestures.action.mute.short", defaultValue: "Mute")
}
}
}
#Preview {
NavigationStack {
TapGesturesSettingsView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}
#endif

View File

@@ -0,0 +1,122 @@
//
// TapZoneActionPicker.swift
// Yattee
//
// Picker for selecting and configuring a tap zone action.
//
#if os(iOS)
import SwiftUI
/// View for selecting and configuring a tap zone's action.
struct TapZoneActionPicker: View {
let position: TapZonePosition
@Binding var action: TapGestureAction
@State private var selectedActionType: TapGestureActionType
@State private var seekSeconds: Int
init(position: TapZonePosition, action: Binding<TapGestureAction>) {
self.position = position
self._action = action
self._selectedActionType = State(initialValue: action.wrappedValue.actionType)
self._seekSeconds = State(initialValue: action.wrappedValue.seekSeconds ?? 10)
}
var body: some View {
List {
Section {
ForEach(TapGestureActionType.allCases) { actionType in
Button {
selectedActionType = actionType
updateAction()
} label: {
HStack {
Label {
Text(actionType.displayName)
} icon: {
Image(systemName: actionType.systemImage)
.foregroundStyle(.tint)
}
Spacer()
if selectedActionType == actionType {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
} header: {
Text(String(localized: "gestures.tap.selectAction", defaultValue: "Select Action"))
}
if selectedActionType.requiresSecondsParameter {
Section {
seekSecondsControl
} header: {
Text(String(localized: "gestures.tap.seekDuration", defaultValue: "Seek Duration"))
}
}
}
.navigationTitle(position.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
private var seekSecondsControl: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(String(localized: "gestures.tap.seconds", defaultValue: "Seconds"))
Spacer()
Text("\(seekSeconds)s")
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(
value: Binding(
get: { Double(seekSeconds) },
set: {
seekSeconds = Int($0)
updateAction()
}
),
in: 1...90,
step: 1
)
// Quick presets
HStack(spacing: 8) {
ForEach([5, 10, 15, 30, 45, 60], id: \.self) { seconds in
Button("\(seconds)s") {
seekSeconds = seconds
updateAction()
}
.buttonStyle(.bordered)
.tint(seekSeconds == seconds ? .accentColor : .secondary)
.controlSize(.small)
}
}
}
}
private func updateAction() {
action = selectedActionType.toAction(seconds: seekSeconds)
}
}
#Preview {
NavigationStack {
TapZoneActionPicker(
position: .left,
action: .constant(.seekBackward(seconds: 10))
)
}
}
#endif

View File

@@ -0,0 +1,148 @@
//
// TapZoneLayoutPicker.swift
// Yattee
//
// Visual picker for selecting tap zone layouts.
//
import SwiftUI
/// Visual grid picker for selecting a tap zone layout.
struct TapZoneLayoutPicker: View {
@Binding var selectedLayout: TapZoneLayout
private let layouts = TapZoneLayout.allCases
private let columns = [
GridItem(.adaptive(minimum: 70, maximum: 90), spacing: 12)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(layouts) { layout in
LayoutOption(
layout: layout,
isSelected: selectedLayout == layout
)
.onTapGesture {
selectedLayout = layout
}
}
}
.padding(.vertical, 8)
}
}
// MARK: - Layout Option
private struct LayoutOption: View {
let layout: TapZoneLayout
let isSelected: Bool
var body: some View {
VStack(spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
LayoutPreviewMiniature(layout: layout)
.padding(6)
}
.frame(width: 70, height: 50)
Text(layout.layoutDescription)
.font(.caption2)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? .primary : .secondary)
}
.contentShape(Rectangle())
}
}
// MARK: - Layout Preview Miniature
private struct LayoutPreviewMiniature: View {
let layout: TapZoneLayout
var body: some View {
GeometryReader { geometry in
let size = geometry.size
switch layout {
case .single:
singleZone(size: size)
case .horizontalSplit:
horizontalSplit(size: size)
case .verticalSplit:
verticalSplit(size: size)
case .threeColumns:
threeColumns(size: size)
case .quadrants:
quadrants(size: size)
}
}
}
private func singleZone(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.4))
.frame(width: size.width, height: size.height)
}
private func horizontalSplit(size: CGSize) -> some View {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.4))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.6))
}
}
private func verticalSplit(size: CGSize) -> some View {
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.4))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.6))
}
}
private func threeColumns(size: CGSize) -> some View {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.4))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.5))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.6))
}
}
private func quadrants(size: CGSize) -> some View {
VStack(spacing: 2) {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.4))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.5))
}
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.5))
RoundedRectangle(cornerRadius: 4)
.fill(Color.accentColor.opacity(0.6))
}
}
}
}
#Preview {
Form {
Section("Layout") {
TapZoneLayoutPicker(selectedLayout: .constant(.horizontalSplit))
}
}
}

View File

@@ -0,0 +1,192 @@
//
// TapZonePreview.swift
// Yattee
//
// Interactive preview showing tap zones that can be tapped to configure.
//
import SwiftUI
/// Interactive preview of tap zones. Tapping a zone opens its configuration.
struct TapZonePreview: View {
let layout: TapZoneLayout
let configurations: [TapZoneConfiguration]
let onZoneTapped: (TapZonePosition) -> Void
@State private var tappedZone: TapZonePosition?
var body: some View {
GeometryReader { geometry in
ZStack {
// Background
RoundedRectangle(cornerRadius: 12)
.fill(Color.black)
// Zone overlays
switch layout {
case .single:
singleLayout(size: geometry.size)
case .horizontalSplit:
horizontalSplitLayout(size: geometry.size)
case .verticalSplit:
verticalSplitLayout(size: geometry.size)
case .threeColumns:
threeColumnsLayout(size: geometry.size)
case .quadrants:
quadrantsLayout(size: geometry.size)
}
}
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Layouts
@ViewBuilder
private func singleLayout(size: CGSize) -> some View {
zoneButton(
position: .full,
frame: CGRect(origin: .zero, size: size)
)
}
@ViewBuilder
private func horizontalSplitLayout(size: CGSize) -> some View {
HStack(spacing: 2) {
zoneButton(position: .left)
zoneButton(position: .right)
}
.padding(2)
}
@ViewBuilder
private func verticalSplitLayout(size: CGSize) -> some View {
VStack(spacing: 2) {
zoneButton(position: .top)
zoneButton(position: .bottom)
}
.padding(2)
}
@ViewBuilder
private func threeColumnsLayout(size: CGSize) -> some View {
HStack(spacing: 2) {
zoneButton(position: .leftThird)
zoneButton(position: .center)
zoneButton(position: .rightThird)
}
.padding(2)
}
@ViewBuilder
private func quadrantsLayout(size: CGSize) -> some View {
VStack(spacing: 2) {
HStack(spacing: 2) {
zoneButton(position: .topLeft)
zoneButton(position: .topRight)
}
HStack(spacing: 2) {
zoneButton(position: .bottomLeft)
zoneButton(position: .bottomRight)
}
}
.padding(2)
}
// MARK: - Zone Button
@ViewBuilder
private func zoneButton(
position: TapZonePosition,
frame: CGRect? = nil
) -> some View {
let config = configurations.first { $0.position == position }
let action = config?.action
Button {
withAnimation(.easeInOut(duration: 0.15)) {
tappedZone = position
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeInOut(duration: 0.15)) {
tappedZone = nil
}
onZoneTapped(position)
}
} label: {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(tappedZone == position ? 0.3 : 0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
)
VStack(spacing: 4) {
if let action {
Image(systemName: action.systemImage)
.font(.title2)
.foregroundStyle(.white)
Text(actionLabel(for: action))
.font(.caption2)
.foregroundStyle(.white.opacity(0.8))
.lineLimit(1)
.minimumScaleFactor(0.8)
} else {
Image(systemName: "questionmark")
.font(.title2)
.foregroundStyle(.white.opacity(0.5))
Text(position.displayName)
.font(.caption2)
.foregroundStyle(.white.opacity(0.5))
}
}
.padding(8)
}
}
.buttonStyle(.plain)
}
private func actionLabel(for action: TapGestureAction) -> String {
switch action {
case .seekForward(let seconds):
"+\(seconds)s"
case .seekBackward(let seconds):
"-\(seconds)s"
case .togglePlayPause:
"Play/Pause"
case .toggleFullscreen:
"Fullscreen"
case .togglePiP:
"PiP"
case .playNext:
"Next"
case .playPrevious:
"Previous"
case .cyclePlaybackSpeed:
"Speed"
case .toggleMute:
"Mute"
}
}
}
#Preview {
Form {
Section {
TapZonePreview(
layout: .quadrants,
configurations: [
TapZoneConfiguration(position: .topLeft, action: .seekBackward(seconds: 10)),
TapZoneConfiguration(position: .topRight, action: .seekForward(seconds: 10)),
TapZoneConfiguration(position: .bottomLeft, action: .playPrevious),
TapZoneConfiguration(position: .bottomRight, action: .playNext)
],
onZoneTapped: { _ in }
)
}
}
}

View File

@@ -0,0 +1,372 @@
//
// MiniPlayerEditorView.swift
// Yattee
//
// Settings editor for mini player behavior.
//
import SwiftUI
struct MiniPlayerEditorView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var showVideo: Bool = true
@State private var videoTapAction: MiniPlayerVideoTapAction = .startPiP
var body: some View {
List {
MiniPlayerPreviewView(
buttons: viewModel.miniPlayerButtons,
showVideo: showVideo
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
videoSection
buttonsSection
addButtonSection
}
.navigationTitle(String(localized: "settings.playerControls.miniPlayer"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Video Section
private var videoSection: some View {
Section {
Toggle(
String(localized: "settings.behavior.miniPlayer.showVideo"),
isOn: $showVideo
)
.disabled(!viewModel.canEditActivePreset)
.onChange(of: showVideo) { _, newValue in
guard newValue != viewModel.miniPlayerShowVideo else { return }
viewModel.syncMiniPlayerShowVideo(newValue)
}
if showVideo {
Picker(
String(localized: "settings.behavior.miniPlayer.videoTapAction"),
selection: $videoTapAction
) {
ForEach(MiniPlayerVideoTapAction.allCases, id: \.self) { action in
Text(action.displayName).tag(action)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: videoTapAction) { _, newValue in
guard newValue != viewModel.miniPlayerVideoTapAction else { return }
viewModel.syncMiniPlayerVideoTapAction(newValue)
}
}
} header: {
Text(String(localized: "settings.behavior.miniPlayer.video.header"))
} footer: {
Text(String(localized: "settings.behavior.miniPlayer.showVideo.footer"))
}
}
// MARK: - Buttons Section
private var buttonsSection: some View {
Section {
if viewModel.miniPlayerButtons.isEmpty {
Text(String(localized: "miniPlayer.buttons.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(Array(viewModel.miniPlayerButtons.enumerated()), id: \.element.id) { _, config in
buttonRow(for: config)
}
.onMove { source, destination in
viewModel.moveMiniPlayerButtons(fromOffsets: source, toOffset: destination)
}
.onDelete { indexSet in
indexSet.forEach { viewModel.removeMiniPlayerButton(at: $0) }
}
}
} header: {
Text(String(localized: "miniPlayer.buttons.header"))
} footer: {
Text(String(localized: "miniPlayer.buttons.footer"))
}
.disabled(!viewModel.canEditActivePreset)
}
@ViewBuilder
private func buttonRow(for config: ControlButtonConfiguration) -> some View {
if config.buttonType.hasSettings {
NavigationLink {
MiniPlayerButtonConfigurationView(
buttonID: config.id,
viewModel: viewModel
)
} label: {
buttonRowContent(for: config)
}
} else {
buttonRowContent(for: config)
}
}
@ViewBuilder
private func buttonRowContent(for config: ControlButtonConfiguration) -> some View {
HStack {
Image(systemName: buttonIcon(for: config))
.frame(width: 24)
.foregroundStyle(.secondary)
Text(config.buttonType.displayName)
// Show configuration summary for configurable buttons
if let summary = configurationSummary(for: config) {
Spacer()
Text(summary)
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
/// Returns the appropriate icon for a button, using configuration-specific icons when available.
private func buttonIcon(for config: ControlButtonConfiguration) -> String {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return seekSettings.systemImage
}
return config.buttonType.systemImage
}
/// Returns a summary string for buttons with configurable settings.
private func configurationSummary(for config: ControlButtonConfiguration) -> String? {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return "\(seekSettings.seconds)s \(seekSettings.direction.displayName)"
}
return nil
}
// MARK: - Add Button Section
private var addButtonSection: some View {
Section {
ForEach(availableButtons, id: \.self) { buttonType in
Button {
viewModel.addMiniPlayerButton(buttonType)
} label: {
HStack {
Image(systemName: buttonType.systemImage)
.frame(width: 24)
.foregroundStyle(.secondary)
Text(buttonType.displayName)
.foregroundStyle(.primary)
}
}
}
} header: {
Text(String(localized: "miniPlayer.addButton.header"))
}
.disabled(!viewModel.canEditActivePreset)
}
// MARK: - Helpers
/// Button types available to add (not already in the mini player).
/// Seek buttons can be added multiple times (like spacers), others are unique.
private var availableButtons: [ControlButtonType] {
let usedTypes = Set(viewModel.miniPlayerButtons.map(\.buttonType))
return ControlButtonType.availableForMiniPlayer.filter { buttonType in
// Seek can be added multiple times (e.g., backward + forward)
buttonType == .seek || !usedTypes.contains(buttonType)
}
}
private func syncLocalState() {
showVideo = viewModel.miniPlayerShowVideo
videoTapAction = viewModel.miniPlayerVideoTapAction
}
}
// MARK: - Mini Player Preview
private struct MiniPlayerPreviewView: View {
let buttons: [ControlButtonConfiguration]
let showVideo: Bool
private let buttonSize: CGFloat = 28
private let buttonSpacing: CGFloat = 4
var body: some View {
HStack(spacing: 12) {
// Video thumbnail placeholder (always shown - matches actual mini player behavior)
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.frame(width: 60, height: 34)
.overlay {
Image(systemName: "play.rectangle")
.foregroundStyle(.secondary)
.font(.system(size: 14))
}
// Title/channel placeholders
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(.secondary.opacity(0.5))
.frame(width: 100, height: 12)
RoundedRectangle(cornerRadius: 2)
.fill(.tertiary.opacity(0.5))
.frame(width: 70, height: 10)
}
Spacer()
// Buttons
HStack(spacing: buttonSpacing) {
ForEach(buttons) { config in
CompactPreviewButtonView(configuration: config, size: buttonSize)
}
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.overlay(Color.black.opacity(0.5))
)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
.padding(.vertical, 12)
}
}
// MARK: - Mini Player Button Configuration View
/// View for configuring a single mini player button's settings.
struct MiniPlayerButtonConfigurationView: View {
let buttonID: UUID
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var seekSeconds: Double = 10
@State private var seekDirection: SeekDirection = .forward
/// Look up the current configuration from the view model's mini player settings.
private var configuration: ControlButtonConfiguration? {
viewModel.miniPlayerButtons.first { $0.id == buttonID }
}
var body: some View {
if let config = configuration {
Form {
// Type-specific settings
if config.buttonType.hasSettings {
typeSpecificSettings(for: config)
}
}
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
}
}
// MARK: - Sync from Configuration
private func syncFromConfiguration(_ config: ControlButtonConfiguration) {
switch config.settings {
case .seek(let settings):
seekSeconds = Double(settings.seconds)
seekDirection = settings.direction
default:
break
}
}
// MARK: - Type-Specific Settings
@ViewBuilder
private func typeSpecificSettings(for config: ControlButtonConfiguration) -> some View {
switch config.buttonType {
case .seek:
seekSettingsSection
default:
EmptyView()
}
}
// MARK: - Seek Settings
@ViewBuilder
private var seekSettingsSection: some View {
Section {
// Direction picker
Picker(
String(localized: "settings.playerControls.seek.direction"),
selection: $seekDirection
) {
ForEach(SeekDirection.allCases, id: \.self) { direction in
Text(direction.displayName).tag(direction)
}
}
.onChange(of: seekDirection) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(seekSeconds), direction: newValue)))
}
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.seek.seconds"))
Spacer()
Text("\(Int(seekSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekSeconds,
in: 1...60,
step: 1
)
.onChange(of: seekSeconds) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(newValue), direction: seekDirection)))
}
#endif
// Quick presets
HStack {
ForEach([5, 10, 15, 30], id: \.self) { preset in
Button("\(preset)s") {
seekSeconds = Double(preset)
updateSettings(.seek(SeekSettings(seconds: preset, direction: seekDirection)))
}
.buttonStyle(.bordered)
.tint(Int(seekSeconds) == preset ? .accentColor : .secondary)
}
}
} header: {
Text(String(localized: "settings.playerControls.seek.header"))
}
}
// MARK: - Update Helpers
private func updateSettings(_ settings: ButtonSettings) {
guard var updated = configuration else { return }
updated.settings = settings
viewModel.updateMiniPlayerButtonConfiguration(updated)
}
}

View File

@@ -0,0 +1,144 @@
//
// PillButtonConfigurationView.swift
// Yattee
//
// View for configuring individual button settings in the player pill.
//
import SwiftUI
/// View for configuring a single pill button's settings.
struct PillButtonConfigurationView: View {
let buttonID: UUID
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var seekSeconds: Double = 10
@State private var seekDirection: SeekDirection = .forward
/// Look up the current configuration from the view model's pill settings.
private var configuration: ControlButtonConfiguration? {
viewModel.pillButtons.first { $0.id == buttonID }
}
var body: some View {
if let config = configuration {
Form {
// Type-specific settings
if config.buttonType.hasSettings {
typeSpecificSettings(for: config)
}
}
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
}
}
// MARK: - Sync from Configuration
private func syncFromConfiguration(_ config: ControlButtonConfiguration) {
switch config.settings {
case .seek(let settings):
seekSeconds = Double(settings.seconds)
seekDirection = settings.direction
default:
break
}
}
// MARK: - Type-Specific Settings
@ViewBuilder
private func typeSpecificSettings(for config: ControlButtonConfiguration) -> some View {
switch config.buttonType {
case .seek:
seekSettingsSection
default:
EmptyView()
}
}
// MARK: - Seek Settings
@ViewBuilder
private var seekSettingsSection: some View {
Section {
// Direction picker
Picker(
String(localized: "settings.playerControls.seek.direction"),
selection: $seekDirection
) {
ForEach(SeekDirection.allCases, id: \.self) { direction in
Text(direction.displayName).tag(direction)
}
}
.onChange(of: seekDirection) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(seekSeconds), direction: newValue)))
}
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.seek.seconds"))
Spacer()
Text("\(Int(seekSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekSeconds,
in: 1...60,
step: 1
)
.onChange(of: seekSeconds) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(newValue), direction: seekDirection)))
}
#endif
// Quick presets
HStack {
ForEach([5, 10, 15, 30], id: \.self) { preset in
Button("\(preset)s") {
seekSeconds = Double(preset)
updateSettings(.seek(SeekSettings(seconds: preset, direction: seekDirection)))
}
.buttonStyle(.bordered)
.tint(Int(seekSeconds) == preset ? .accentColor : .secondary)
}
}
} header: {
Text(String(localized: "settings.playerControls.seek.header"))
}
}
// MARK: - Update Helpers
private func updateSettings(_ settings: ButtonSettings) {
guard var updated = configuration else { return }
updated.settings = settings
viewModel.updatePillButtonConfiguration(updated)
}
}
// MARK: - Preview
#Preview {
NavigationStack {
PillButtonConfigurationView(
buttonID: UUID(),
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}

View File

@@ -0,0 +1,535 @@
//
// PlayerControlsPreviewView.swift
// Yattee
//
// Preview component showing player controls layout in settings.
//
import SwiftUI
// MARK: - Preview Theme Modifier
/// A view modifier that applies the controls theme color scheme to preview.
struct PreviewThemeModifier: ViewModifier {
let theme: ControlsTheme
@Environment(\.colorScheme) private var systemColorScheme
func body(content: Content) -> some View {
if let forcedScheme = theme.colorScheme {
content.environment(\.colorScheme, forcedScheme)
} else {
// Default to dark for preview since it's on black background
content.environment(\.colorScheme, .dark)
}
}
}
/// A static preview of the player controls layout for the settings view.
struct PlayerControlsPreviewView: View {
let layout: PlayerControlsLayout
let isLandscape: Bool
var body: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
let containerHeight = geometry.size.height
// Use 16:9 aspect ratio for both modes, capped at container height
let aspectRatioHeight = containerWidth / (16.0 / 9.0)
let previewWidth = containerWidth
let previewHeight = min(containerHeight, aspectRatioHeight)
previewContent(width: previewWidth, height: previewHeight)
.position(x: containerWidth / 2, y: containerHeight / 2)
}
}
// MARK: - Preview Content
@ViewBuilder
private func previewContent(width: CGFloat, height: CGFloat) -> some View {
// Calculate minimum width needed for all buttons
let minContentWidth = calculateMinimumContentWidth()
let contentWidth = max(width, minContentWidth)
let needsScrolling = contentWidth > width
ScrollView(.horizontal, showsIndicators: needsScrolling) {
previewContentView(width: contentWidth, height: height)
.frame(width: contentWidth, height: height)
}
.scrollDisabled(!needsScrolling)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 8))
.modifier(PreviewThemeModifier(theme: layout.globalSettings.theme))
}
/// Calculate minimum width needed to display all buttons without overlap
private func calculateMinimumContentWidth() -> CGFloat {
let topButtons = visibleButtons(in: layout.topSection)
let bottomButtons = visibleButtons(in: layout.bottomSection)
let topWidth = calculateSectionWidth(buttons: topButtons)
let bottomWidth = calculateSectionWidth(buttons: bottomButtons)
// Return the larger of top/bottom, plus padding
return max(topWidth, bottomWidth) + (previewPadding * 2)
}
/// Whether buttons have glass backgrounds (affects frame sizes).
private var hasButtonBackground: Bool {
layout.globalSettings.buttonBackground.glassStyle != nil
}
/// Frame size for regular buttons - matches ControlsSectionRenderer
private var regularButtonFrameSize: CGFloat {
hasButtonBackground ? previewButtonBackgroundSize : previewButtonSize
}
/// Frame size for slider icons - matches ControlsSectionRenderer
private var sliderIconFrameSize: CGFloat {
hasButtonBackground ? previewButtonBackgroundSize : previewButtonSize
}
/// Calculate width needed for a section's buttons
private func calculateSectionWidth(buttons: [ControlButtonConfiguration]) -> CGFloat {
var totalWidth: CGFloat = 0
var hasFlexibleSpacer = false
let sliderWidth: CGFloat = 80 * previewScale // Matches actual player slider width
for button in buttons {
if button.buttonType == .spacer {
if let settings = button.settings,
case .spacer(let spacerSettings) = settings {
if spacerSettings.isFlexible {
hasFlexibleSpacer = true
totalWidth += 20 // Minimum width for flexible spacer
} else {
totalWidth += CGFloat(spacerSettings.fixedWidth) * previewScale
}
}
} else if button.buttonType == .timeDisplay {
totalWidth += 60 // Approximate width for time display text
} else if button.buttonType == .brightness || button.buttonType == .volume {
let behavior = button.sliderSettings?.sliderBehavior ?? .expandOnTap
let effectiveBehavior: SliderBehavior = {
if behavior == .autoExpandInLandscape {
return isLandscape ? .alwaysVisible : .expandOnTap
}
return behavior
}()
if effectiveBehavior == .alwaysVisible {
// Slider icon + spacing + slider track
totalWidth += sliderIconFrameSize + 3 + sliderWidth
} else {
totalWidth += sliderIconFrameSize
}
} else if button.buttonType == .titleAuthor {
// Title/Author button is wider - estimate based on settings
let settings = button.titleAuthorSettings ?? TitleAuthorSettings()
var width: CGFloat = 0
if settings.showSourceImage { width += previewButtonSize * 1.2 + 4 }
if settings.showTitle || settings.showSourceName { width += 50 * previewScale }
if hasButtonBackground { width += 12 } // padding
totalWidth += max(width, regularButtonFrameSize)
} else {
totalWidth += regularButtonFrameSize
}
totalWidth += previewButtonSpacing
}
// If there's a flexible spacer, add extra minimum space
if hasFlexibleSpacer {
totalWidth += 40
}
return totalWidth
}
@ViewBuilder
private func previewContentView(width: CGFloat, height: CGFloat) -> some View {
// Video background
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
.overlay {
// Solid color shade matching the controls opacity setting
Color.black.opacity(layout.globalSettings.controlsFadeOpacity)
}
.overlay {
// Controls overlay using ZStack for precise positioning
ZStack {
// Top section - aligned to top trailing
VStack {
HStack(spacing: previewButtonSpacing) {
ForEach(visibleButtons(in: layout.topSection)) { button in
PreviewButton(
configuration: button,
size: previewButtonSize,
backgroundSize: previewButtonBackgroundSize,
isLandscape: isLandscape,
fontStyle: layout.globalSettings.fontStyle,
buttonBackground: layout.globalSettings.buttonBackground,
previewScale: previewScale,
buttonSize: layout.globalSettings.buttonSize
)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, previewPadding)
.padding(.top, previewPadding)
Spacer()
}
// Center section
HStack(spacing: previewCenterSpacing) {
if layout.centerSettings.showSeekBackward {
PreviewCenterButton(
systemImage: layout.centerSettings.seekBackwardSystemImage,
fontSize: previewSeekFontSize,
frameSize: previewSeekButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
if layout.centerSettings.showPlayPause {
PreviewCenterButton(
systemImage: "play.fill",
fontSize: previewPlayFontSize,
frameSize: previewPlayButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
if layout.centerSettings.showSeekForward {
PreviewCenterButton(
systemImage: layout.centerSettings.seekForwardSystemImage,
fontSize: previewSeekFontSize,
frameSize: previewSeekButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
}
// Bottom section - aligned to bottom leading
VStack {
Spacer()
HStack(spacing: previewButtonSpacing) {
ForEach(visibleButtons(in: layout.bottomSection)) { button in
PreviewButton(
configuration: button,
size: previewButtonSize,
backgroundSize: previewButtonBackgroundSize,
isLandscape: isLandscape,
fontStyle: layout.globalSettings.fontStyle,
buttonBackground: layout.globalSettings.buttonBackground,
previewScale: previewScale,
buttonSize: layout.globalSettings.buttonSize
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, previewPadding)
.padding(.bottom, previewPadding)
}
}
}
}
// MARK: - Layout Constants
/// Scale factor for preview (to fit in settings view)
private var previewScale: CGFloat { 0.55 }
/// Button size for top/bottom bars - matches ControlsSectionRenderer which uses buttonSize.pointSize
private var previewButtonSize: CGFloat {
layout.globalSettings.buttonSize.pointSize * previewScale
}
/// Background size for buttons (slightly larger than button, matching ControlsSectionRenderer)
private var previewButtonBackgroundSize: CGFloat {
layout.globalSettings.buttonSize.pointSize * 1.15 * previewScale
}
/// Center button sizes - matches PlayerControlsView hardcoded values
private var previewSeekButtonSize: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 64 : 56) * previewScale
}
private var previewPlayButtonSize: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 82 : 72) * previewScale
}
/// Font sizes for center buttons - matches PlayerControlsView hardcoded values
private var previewSeekFontSize: CGFloat { 36 * previewScale }
private var previewPlayFontSize: CGFloat { 56 * previewScale }
private var previewButtonSpacing: CGFloat { 8 }
private var previewCenterSpacing: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 40 : 32) * previewScale
}
private var previewPadding: CGFloat { 12 }
// MARK: - Helpers
private func visibleButtons(in section: LayoutSection) -> [ControlButtonConfiguration] {
section.visibleButtons(isWideLayout: isLandscape)
}
}
// MARK: - Preview Button
private struct PreviewButton: View {
let configuration: ControlButtonConfiguration
/// Button frame size (for tap target)
let size: CGFloat
/// Background frame size (for glass background, slightly larger)
let backgroundSize: CGFloat
let isLandscape: Bool
let fontStyle: ControlsFontStyle
let buttonBackground: ButtonBackgroundStyle
/// Scale factor for preview (used for slider width calculations)
let previewScale: CGFloat
/// Button size setting (small/medium/large) for icon scaling
let buttonSize: ButtonSize
/// Whether this button should show a glass background.
private var hasBackground: Bool { buttonBackground.glassStyle != nil }
/// Frame size - use backgroundSize when glass background enabled
private var frameSize: CGFloat { hasBackground ? backgroundSize : size }
/// Icon font size - uses buttonSize.iconSize to match actual player renderer
private var iconFontSize: CGFloat { buttonSize.iconSize * previewScale }
var body: some View {
Group {
if configuration.buttonType == .spacer {
// Spacer
if let settings = configuration.settings,
case .spacer(let spacerSettings) = settings,
spacerSettings.isFlexible {
Spacer()
} else {
Rectangle()
.fill(Color.clear)
.frame(width: spacerWidth)
}
} else if configuration.buttonType == .timeDisplay {
// Time display
Text("0:00 / 3:45")
.font(fontStyle.font(.caption))
.foregroundStyle(.white.opacity(0.9))
} else if configuration.buttonType == .brightness || configuration.buttonType == .volume {
// Slider buttons (brightness/volume) - show based on behavior
sliderPreview
} else if configuration.buttonType == .titleAuthor {
// Title/Author button - show preview matching actual layout
titleAuthorPreview
} else {
// Regular button
regularButtonContent
}
}
.opacity(buttonOpacity)
}
// MARK: - Regular Button Content
@ViewBuilder
private var regularButtonContent: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
// MARK: - Title/Author Preview
@ViewBuilder
private var titleAuthorPreview: some View {
let settings = configuration.titleAuthorSettings ?? TitleAuthorSettings()
let avatarSize = size * 1.2
HStack(spacing: 4) {
// Avatar placeholder
if settings.showSourceImage {
Circle()
.fill(.white.opacity(0.3))
.frame(width: avatarSize, height: avatarSize)
.overlay {
Text("Y")
.font(.system(size: size * 0.6, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
}
}
// Title and author stack
if settings.showTitle || settings.showSourceName {
VStack(alignment: .leading, spacing: 0) {
if settings.showTitle {
Text("Video Title")
.font(fontStyle.font(size: size * 0.5, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
}
if settings.showSourceName {
Text("Channel")
.font(fontStyle.font(size: size * 0.4))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, buttonBackground.glassStyle != nil ? 6 : 0)
.padding(.vertical, buttonBackground.glassStyle != nil ? 3 : 0)
.modifier(OptionalCapsuleGlassBackgroundModifier(style: buttonBackground))
}
// MARK: - Slider Preview
@ViewBuilder
private var sliderPreview: some View {
let behavior = configuration.sliderSettings?.sliderBehavior ?? .expandOnTap
// Compute effective behavior based on orientation for autoExpandInLandscape
let effectiveBehavior: SliderBehavior = {
if behavior == .autoExpandInLandscape {
return isLandscape ? .alwaysVisible : .expandOnTap
}
return behavior
}()
HStack(spacing: 3) {
// Icon with optional glass background
sliderIconContent
// Fake slider - show when effectively always visible
if effectiveBehavior == .alwaysVisible {
ZStack(alignment: .leading) {
// Slider track - 80pt width in actual player, scaled for preview
let sliderWidth: CGFloat = 80 * previewScale
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.3))
.frame(width: sliderWidth, height: 3)
// Slider fill (showing ~60% filled)
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.8))
.frame(width: sliderWidth * 0.6, height: 3)
}
}
}
}
@ViewBuilder
private var sliderIconContent: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
private var spacerWidth: CGFloat {
guard let settings = configuration.settings,
case .spacer(let spacerSettings) = settings else {
return 8
}
return CGFloat(spacerSettings.fixedWidth) * previewScale
}
private var buttonOpacity: Double {
// Could dim buttons that don't match current orientation
1.0
}
}
// MARK: - Preview Center Button
private struct PreviewCenterButton: View {
let systemImage: String
let fontSize: CGFloat
let frameSize: CGFloat
let buttonBackground: ButtonBackgroundStyle
/// Background frame size (slightly larger when background is enabled).
private var backgroundFrameSize: CGFloat {
buttonBackground.glassStyle != nil ? frameSize * 1.15 : frameSize
}
var body: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: systemImage)
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: backgroundFrameSize, height: backgroundFrameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: systemImage)
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
}
}
}
// MARK: - Optional Capsule Glass Background Modifier
/// A view modifier that conditionally applies a capsule glass background.
private struct OptionalCapsuleGlassBackgroundModifier: ViewModifier {
let style: ButtonBackgroundStyle
func body(content: Content) -> some View {
if let glassStyle = style.glassStyle {
content.glassBackground(glassStyle, in: .capsule, fallback: .ultraThinMaterial)
} else {
content
}
}
}
// MARK: - Preview
#Preview("Landscape") {
PlayerControlsPreviewView(
layout: .default,
isLandscape: true
)
.frame(height: 200)
.padding()
.background(Color.gray.opacity(0.2))
}
#Preview("Portrait") {
PlayerControlsPreviewView(
layout: .default,
isLandscape: false
)
.frame(height: 300)
.padding()
.background(Color.gray.opacity(0.2))
}

View File

@@ -0,0 +1,624 @@
//
// 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()
}
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()
}
}
} 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)
}

View File

@@ -0,0 +1,208 @@
//
// PlayerPillEditorView.swift
// Yattee
//
// Settings editor for player pill visibility, collapse mode, and buttons.
//
import SwiftUI
struct PlayerPillEditorView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var visibility: PillVisibility = .portraitOnly
var body: some View {
List {
if !viewModel.pillButtons.isEmpty {
PillPreviewView(buttons: viewModel.pillButtons)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
visibilitySection
buttonsSection
addButtonSection
}
.navigationTitle(String(localized: "settings.playerControls.playerPill"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Visibility Section
private var visibilitySection: some View {
Section {
Picker(
String(localized: "pill.visibility.title"),
selection: $visibility
) {
ForEach(PillVisibility.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: visibility) { _, newValue in
guard newValue != viewModel.pillVisibility else { return }
viewModel.syncPillVisibility(newValue)
}
} header: {
Text(String(localized: "pill.visibility.header"))
} footer: {
Text(String(localized: "pill.visibility.footer"))
}
}
// MARK: - Buttons Section
private var buttonsSection: some View {
Section {
if viewModel.pillButtons.isEmpty {
Text(String(localized: "pill.buttons.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(Array(viewModel.pillButtons.enumerated()), id: \.element.id) { index, config in
buttonRow(for: config)
}
.onMove { source, destination in
viewModel.movePillButtons(fromOffsets: source, toOffset: destination)
}
.onDelete { indexSet in
indexSet.forEach { viewModel.removePillButton(at: $0) }
}
}
} header: {
Text(String(localized: "pill.buttons.header"))
} footer: {
Text(String(localized: "pill.buttons.footer"))
}
.disabled(!viewModel.canEditActivePreset)
}
@ViewBuilder
private func buttonRow(for config: ControlButtonConfiguration) -> some View {
if config.buttonType.hasSettings {
NavigationLink {
PillButtonConfigurationView(
buttonID: config.id,
viewModel: viewModel
)
} label: {
buttonRowContent(for: config)
}
} else {
buttonRowContent(for: config)
}
}
@ViewBuilder
private func buttonRowContent(for config: ControlButtonConfiguration) -> some View {
HStack {
Image(systemName: buttonIcon(for: config))
.frame(width: 24)
.foregroundStyle(.secondary)
Text(config.buttonType.displayName)
// Show configuration summary for configurable buttons
if let summary = configurationSummary(for: config) {
Spacer()
Text(summary)
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
/// Returns the appropriate icon for a button, using configuration-specific icons when available.
private func buttonIcon(for config: ControlButtonConfiguration) -> String {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return seekSettings.systemImage
}
return config.buttonType.systemImage
}
/// Returns a summary string for buttons with configurable settings.
private func configurationSummary(for config: ControlButtonConfiguration) -> String? {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return "\(seekSettings.seconds)s \(seekSettings.direction.displayName)"
}
return nil
}
// MARK: - Add Button Section
private var addButtonSection: some View {
Section {
ForEach(availableButtons, id: \.self) { buttonType in
Button {
viewModel.addPillButton(buttonType)
} label: {
HStack {
Image(systemName: buttonType.systemImage)
.frame(width: 24)
.foregroundStyle(.secondary)
Text(buttonType.displayName)
.foregroundStyle(.primary)
}
}
}
} header: {
Text(String(localized: "pill.addButton.header"))
}
.disabled(!viewModel.canEditActivePreset)
}
// MARK: - Helpers
/// Button types available to add (not already in the pill).
/// Seek buttons can be added multiple times (like spacers), others are unique.
private var availableButtons: [ControlButtonType] {
let usedTypes = Set(viewModel.pillButtons.map(\.buttonType))
return ControlButtonType.availableForPill.filter { buttonType in
// Seek can be added multiple times (e.g., backward + forward)
buttonType == .seek || !usedTypes.contains(buttonType)
}
}
private func syncLocalState() {
visibility = viewModel.pillVisibility
}
}
// MARK: - Pill Preview
private struct PillPreviewView: View {
let buttons: [ControlButtonConfiguration]
private let buttonSize: CGFloat = 24
private let buttonSpacing: CGFloat = 8
var body: some View {
HStack(spacing: buttonSpacing) {
ForEach(buttons) { config in
CompactPreviewButtonView(configuration: config, size: buttonSize)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.overlay(Color.black.opacity(0.5))
)
.clipShape(Capsule())
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
// Preview requires AppEnvironment - use app to test

View File

@@ -0,0 +1,170 @@
//
// PresetEditorView.swift
// Yattee
//
// Sheet view for creating, renaming, or duplicating a preset.
//
import SwiftUI
/// Mode for the preset editor.
enum PresetEditorMode {
case create(baseLayouts: [LayoutPreset], activePreset: LayoutPreset?)
case rename(currentName: String)
var title: String {
switch self {
case .create:
return String(localized: "settings.playerControls.newPreset")
case .rename:
return String(localized: "settings.playerControls.renamePreset")
}
}
var placeholder: String {
switch self {
case .create, .rename:
return String(localized: "settings.playerControls.presetNamePlaceholder")
}
}
var initialValue: String {
switch self {
case .create:
return ""
case .rename(let currentName):
return currentName
}
}
var saveButtonTitle: String {
switch self {
case .create:
return String(localized: "settings.playerControls.create")
case .rename:
return String(localized: "settings.playerControls.save")
}
}
}
/// Sheet view for creating or renaming a preset.
struct PresetEditorView: View {
@Environment(\.dismiss) private var dismiss
let mode: PresetEditorMode
let onSave: (String, UUID?) -> Void
@State private var name: String
@State private var selectedBaseLayoutID: UUID?
@FocusState private var isNameFocused: Bool
private var baseLayouts: [LayoutPreset] {
if case .create(let layouts, _) = mode {
return layouts
}
return []
}
init(mode: PresetEditorMode, onSave: @escaping (String, UUID?) -> Void) {
self.mode = mode
self.onSave = onSave
_name = State(initialValue: mode.initialValue)
// Set default selection to active preset for create mode
if case .create(_, let activePreset) = mode {
_selectedBaseLayoutID = State(initialValue: activePreset?.id)
}
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var isValid: Bool {
!trimmedName.isEmpty && trimmedName.count <= LayoutPreset.maxNameLength
}
var body: some View {
NavigationStack {
Form {
// Base Layout Picker (only for create mode)
if case .create(_, _) = mode {
Section {
Picker(
String(localized: "settings.playerControls.baseLayout"),
selection: $selectedBaseLayoutID
) {
ForEach(baseLayouts) { preset in
Text(preset.name).tag(preset.id as UUID?)
}
}
}
}
Section {
TextField(mode.placeholder, text: $name)
.focused($isNameFocused)
.submitLabel(.done)
.onSubmit(saveIfValid)
} footer: {
HStack {
if trimmedName.count > LayoutPreset.maxNameLength {
Text(String(localized: "settings.playerControls.nameTooLong"))
.foregroundStyle(.red)
}
Spacer()
Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
.foregroundStyle(
trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary
)
}
}
}
.navigationTitle(mode.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "settings.playerControls.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(mode.saveButtonTitle) {
saveIfValid()
}
.disabled(!isValid)
}
}
.onAppear {
isNameFocused = true
}
}
#if os(iOS)
.presentationDetents([.medium])
#endif
}
private func saveIfValid() {
guard isValid else { return }
onSave(trimmedName, selectedBaseLayoutID)
dismiss()
}
}
// MARK: - Preview
#Preview("Create") {
PresetEditorView(
mode: .create(
baseLayouts: LayoutPreset.allBuiltIn(),
activePreset: LayoutPreset.defaultPreset()
)
) { _, _ in }
}
#Preview("Rename") {
PresetEditorView(mode: .rename(currentName: "My Custom Preset")) { _, _ in }
}

View File

@@ -0,0 +1,504 @@
//
// PresetSelectorView.swift
// Yattee
//
// View for selecting and managing player controls presets.
//
import SwiftUI
#if !os(tvOS)
import UniformTypeIdentifiers
#endif
// MARK: - Export File Wrapper
#if !os(tvOS)
private struct ExportFile: Identifiable {
let id = UUID()
let url: URL
}
#endif
/// Pending preset creation request
private struct PendingPresetCreation: Equatable {
let name: String
let basePresetID: UUID?
}
/// Pending preset rename request
private struct PendingPresetRename: Equatable {
let presetID: UUID
let newName: String
}
/// Notification posted when a preset is selected in PresetSelectorView
extension Notification.Name {
static let presetSelectionDidChange = Notification.Name("presetSelectionDidChange")
}
/// View for selecting and managing player controls layout presets.
struct PresetSelectorView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
var onPresetSelected: ((String) -> Void)?
@State private var showCreateSheet = false
@State private var presetToRename: LayoutPreset?
@State private var pendingCreation: PendingPresetCreation?
@State private var pendingRename: PendingPresetRename?
@State private var listRefreshID = UUID()
// Track active preset ID locally to force view updates
@State private var trackedActivePresetID: UUID?
// Import/Export state
#if !os(tvOS)
@State private var showingImportPicker = false
@State private var isImporting = false
@State private var importError: String?
@State private var showingImportError = false
@State private var importedPresetName: String?
@State private var showingImportSuccess = false
@State private var exportFile: ExportFile?
#endif
var body: some View {
presetList
.navigationTitle(String(localized: "settings.playerControls.presets"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { toolbarContent }
.sheet(isPresented: $showCreateSheet) { createPresetSheet }
.sheet(item: $presetToRename) { preset in renamePresetSheet(preset) }
#if os(iOS)
.sheet(isPresented: $showingImportPicker) { importPickerSheet }
.sheet(item: $exportFile) { file in ShareSheet(items: [file.url]) }
#endif
#if !os(tvOS)
.alert(
String(localized: "settings.playerControls.import.error.title"),
isPresented: $showingImportError
) {
Button(String(localized: "settings.playerControls.ok")) {}
} message: {
if let error = importError {
Text(error)
}
}
.alert(
String(localized: "settings.playerControls.import.success.title"),
isPresented: $showingImportSuccess
) {
Button(String(localized: "settings.playerControls.ok")) {}
} message: {
if let name = importedPresetName {
Text(String(localized: "settings.playerControls.import.success.message \(name)"))
}
}
#endif
.task(id: pendingCreation) {
guard let creation = pendingCreation else { return }
let basePreset = creation.basePresetID.flatMap { id in
viewModel.presets.first { $0.id == id }
}
await viewModel.createPreset(name: creation.name, basedOn: basePreset)
pendingCreation = nil
// Force view update by updating local tracked state
trackedActivePresetID = viewModel.activePreset?.id
listRefreshID = UUID()
// Notify parent of selection change
if let name = viewModel.activePreset?.name {
onPresetSelected?(name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: name)
}
}
.task(id: pendingRename) {
guard let rename = pendingRename else { return }
if let preset = viewModel.presets.first(where: { $0.id == rename.presetID }) {
await viewModel.renamePreset(preset, to: rename.newName)
}
pendingRename = nil
listRefreshID = UUID()
}
.onAppear {
trackedActivePresetID = viewModel.activePreset?.id
}
}
// MARK: - View Components
private var presetList: some View {
List {
builtInPresetsSection
customPresetsSection
}
.id(listRefreshID)
}
private var builtInPresetsSection: some View {
Section {
ForEach(viewModel.builtInPresets) { preset in
PresetRow(
preset: preset,
isActive: preset.id == trackedActivePresetID,
onSelect: {
viewModel.selectPreset(preset)
trackedActivePresetID = preset.id
onPresetSelected?(preset.name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
}
)
}
} header: {
Text(String(localized: "settings.playerControls.builtInPresets"))
}
}
@ViewBuilder
private var customPresetsSection: some View {
if !viewModel.customPresets.isEmpty {
Section {
ForEach(viewModel.customPresets) { preset in
customPresetRow(preset)
}
} header: {
Text(String(localized: "settings.playerControls.customPresets"))
}
}
}
private func customPresetRow(_ preset: LayoutPreset) -> some View {
PresetRow(
preset: preset,
isActive: preset.id == trackedActivePresetID,
onSelect: {
viewModel.selectPreset(preset)
trackedActivePresetID = preset.id
onPresetSelected?(preset.name)
NotificationCenter.default.post(name: .presetSelectionDidChange, object: preset.name)
},
onRename: { presetToRename = preset },
onExport: {
#if !os(tvOS)
exportPreset(preset)
#endif
},
onDelete: {
Task { await viewModel.deletePreset(preset) }
},
canDelete: preset.id != trackedActivePresetID
)
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
#if !os(tvOS)
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showCreateSheet = true
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
Divider()
Button {
showImportPicker()
} label: {
Label(
String(localized: "settings.playerControls.importPreset"),
systemImage: "square.and.arrow.down"
)
}
.disabled(isImporting)
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
}
#else
ToolbarItem(placement: .primaryAction) {
Button {
showCreateSheet = true
} label: {
Label(
String(localized: "settings.playerControls.newPreset"),
systemImage: "plus"
)
}
}
#endif
}
private var createPresetSheet: some View {
PresetEditorView(
mode: .create(
baseLayouts: viewModel.presets,
activePreset: viewModel.activePreset
),
onSave: { name, basePresetID in
pendingCreation = PendingPresetCreation(name: name, basePresetID: basePresetID)
}
)
}
private func renamePresetSheet(_ preset: LayoutPreset) -> some View {
let presetID = preset.id
return PresetEditorView(
mode: .rename(currentName: preset.name),
onSave: { name, _ in
pendingRename = PendingPresetRename(presetID: presetID, newName: name)
}
)
}
#if os(iOS)
private var importPickerSheet: some View {
PresetFilePickerView { url in
handleImportedFile(url)
}
}
#endif
// MARK: - Import/Export Actions
#if !os(tvOS)
private func showImportPicker() {
#if os(iOS)
showingImportPicker = true
#elseif os(macOS)
showMacOSImportPanel()
#endif
}
#if os(macOS)
private func showMacOSImportPanel() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = String(localized: "settings.playerControls.import.panel.message")
if panel.runModal() == .OK, let url = panel.url {
handleImportedFile(url)
}
}
#endif
private func handleImportedFile(_ url: URL) {
isImporting = true
Task {
do {
let presetName = try await viewModel.importPreset(from: url)
await MainActor.run {
isImporting = false
importedPresetName = presetName
showingImportSuccess = true
}
} catch {
await MainActor.run {
isImporting = false
importError = error.localizedDescription
showingImportError = true
}
}
}
}
private func exportPreset(_ preset: LayoutPreset) {
guard let url = viewModel.exportPreset(preset) else { return }
#if os(iOS)
exportFile = ExportFile(url: url)
#elseif os(macOS)
showMacOSSavePanel(url: url, preset: preset)
#endif
}
#if os(macOS)
private func showMacOSSavePanel(url: URL, preset: LayoutPreset) {
let panel = NSSavePanel()
panel.nameFieldStringValue = PlayerControlsPresetExportImport.generateExportFilename(for: preset)
panel.allowedContentTypes = [.json]
if panel.runModal() == .OK, let saveURL = panel.url {
do {
// Remove existing file if it exists (NSSavePanel asks for confirmation)
if FileManager.default.fileExists(atPath: saveURL.path) {
try FileManager.default.removeItem(at: saveURL)
}
try FileManager.default.copyItem(at: url, to: saveURL)
} catch {
LoggingService.shared.error("Failed to save preset file: \(error.localizedDescription)")
}
}
}
#endif
#endif
}
// MARK: - Preset Row
private struct PresetRow: View {
let preset: LayoutPreset
let isActive: Bool
let onSelect: () -> Void
var onRename: (() -> Void)? = nil
var onExport: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
var canDelete: Bool = true
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(preset.name)
.foregroundStyle(.primary)
if preset.isBuiltIn {
Text(String(localized: "settings.playerControls.builtIn"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if isActive {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if !os(tvOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if let onDelete, !preset.isBuiltIn, canDelete {
Button(role: .destructive) {
onDelete()
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
if let onRename, !preset.isBuiltIn {
Button {
onRename()
} label: {
Label(
String(localized: "settings.playerControls.rename"),
systemImage: "pencil"
)
}
.tint(.orange)
}
if let onExport, !preset.isBuiltIn {
Button {
onExport()
} label: {
Label(
String(localized: "settings.playerControls.exportPreset"),
systemImage: "square.and.arrow.up"
)
}
.tint(.blue)
}
}
#endif
.contextMenu {
if let onRename, !preset.isBuiltIn {
Button {
onRename()
} label: {
Label(
String(localized: "settings.playerControls.rename"),
systemImage: "pencil"
)
}
}
if let onExport, !preset.isBuiltIn {
Button {
onExport()
} label: {
Label(
String(localized: "settings.playerControls.exportPreset"),
systemImage: "square.and.arrow.up"
)
}
}
if let onDelete, !preset.isBuiltIn, canDelete {
Divider()
Button(role: .destructive) {
onDelete()
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
}
}
}
// MARK: - File Picker (iOS)
#if os(iOS)
private struct PresetFilePickerView: UIViewControllerRepresentable {
let onSelect: (URL) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.json])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let onSelect: (URL) -> Void
init(onSelect: @escaping (URL) -> Void) {
self.onSelect = onSelect
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onSelect(url)
}
}
}
#endif
// MARK: - Preview
#Preview {
NavigationStack {
PresetSelectorView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}

View File

@@ -0,0 +1,196 @@
//
// ProgressBarSettingsView.swift
// Yattee
//
// View for configuring progress bar appearance settings.
//
import SwiftUI
/// View for configuring progress bar settings.
struct ProgressBarSettingsView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
@State private var playedColor: Color = .red
@State private var showChapters: Bool = true
@State private var showSponsorSegments: Bool = true
@State private var categorySettings: [String: SponsorBlockCategorySettings] = [:]
var body: some View {
Form {
appearanceSection
chaptersSection
sponsorSegmentsSection
if showSponsorSegments {
segmentColorsSection
}
}
.navigationTitle(String(localized: "settings.playerControls.progressBar"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
loadSettings()
}
}
// MARK: - Appearance Section
private var appearanceSection: some View {
Section {
#if !os(tvOS)
ColorPicker(
String(localized: "settings.playerControls.progressBar.playedColor"),
selection: $playedColor,
supportsOpacity: false
)
.onChange(of: playedColor) { _, newValue in
viewModel.updateProgressBarSettingsSync { $0.playedColor = CodableColor(newValue) }
}
.disabled(!viewModel.canEditActivePreset)
#else
HStack {
Text(String(localized: "settings.playerControls.progressBar.playedColor"))
Spacer()
Circle()
.fill(playedColor)
.frame(width: 24, height: 24)
}
#endif
} header: {
Text(String(localized: "settings.playerControls.progressBar.appearance"))
}
}
// MARK: - Chapters Section
private var chaptersSection: some View {
Section {
Toggle(
String(localized: "settings.playerControls.progressBar.showChapters"),
isOn: $showChapters
)
.onChange(of: showChapters) { _, newValue in
viewModel.updateProgressBarSettingsSync { $0.showChapters = newValue }
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "settings.playerControls.progressBar.chapters"))
} footer: {
Text(String(localized: "settings.playerControls.progressBar.chaptersFooter"))
}
}
// MARK: - Sponsor Segments Section
private var sponsorSegmentsSection: some View {
Section {
Toggle(
String(localized: "settings.playerControls.progressBar.showSponsorSegments"),
isOn: $showSponsorSegments
)
.onChange(of: showSponsorSegments) { _, newValue in
viewModel.updateProgressBarSettingsSync {
$0.sponsorBlockSettings.showSegments = newValue
}
}
.disabled(!viewModel.canEditActivePreset)
} header: {
Text(String(localized: "settings.playerControls.progressBar.sponsorSegments"))
}
}
// MARK: - Segment Colors Section
private var segmentColorsSection: some View {
Section {
ForEach(SponsorBlockCategory.allCases, id: \.rawValue) { category in
categoryRow(for: category)
}
} header: {
Text(String(localized: "settings.playerControls.progressBar.segmentColors"))
}
}
@ViewBuilder
private func categoryRow(for category: SponsorBlockCategory) -> some View {
let settings = categorySettings[category.rawValue]
?? SponsorBlockSegmentSettings.defaultCategorySettings[category.rawValue]
?? SponsorBlockCategorySettings()
let isVisible = Binding<Bool>(
get: { settings.isVisible },
set: { newValue in
updateCategorySettings(for: category) { $0.isVisible = newValue }
}
)
let color = Binding<Color>(
get: { settings.color.color },
set: { newValue in
updateCategorySettings(for: category) { $0.color = CodableColor(newValue) }
}
)
HStack {
Toggle(category.displayName, isOn: isVisible)
.disabled(!viewModel.canEditActivePreset)
Spacer()
#if !os(tvOS)
ColorPicker("", selection: color, supportsOpacity: false)
.labelsHidden()
.disabled(!viewModel.canEditActivePreset || !settings.isVisible)
#else
Circle()
.fill(color.wrappedValue)
.frame(width: 24, height: 24)
.opacity(settings.isVisible ? 1 : 0.4)
#endif
}
}
// MARK: - Helpers
private func loadSettings() {
let progressBarSettings = viewModel.progressBarSettings
playedColor = progressBarSettings.playedColor.color
showChapters = progressBarSettings.showChapters
showSponsorSegments = progressBarSettings.sponsorBlockSettings.showSegments
categorySettings = progressBarSettings.sponsorBlockSettings.categorySettings
}
private func updateCategorySettings(
for category: SponsorBlockCategory,
mutation: (inout SponsorBlockCategorySettings) -> Void
) {
var settings = categorySettings[category.rawValue]
?? SponsorBlockSegmentSettings.defaultCategorySettings[category.rawValue]
?? SponsorBlockCategorySettings()
mutation(&settings)
categorySettings[category.rawValue] = settings
viewModel.updateProgressBarSettingsSync {
$0.sponsorBlockSettings = $0.sponsorBlockSettings.withUpdatedSettings(
forKey: category.rawValue,
settings
)
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
ProgressBarSettingsView(
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}

View File

@@ -0,0 +1,479 @@
//
// SectionEditorView.swift
// Yattee
//
// View for editing buttons in a player controls section (top or bottom).
//
import SwiftUI
/// View for editing buttons in a specific section of the player controls.
struct SectionEditorView: View {
let sectionType: LayoutSectionType
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var buttons: [ControlButtonConfiguration] = []
@State private var availableTypes: [ControlButtonType] = []
var body: some View {
List {
// Section preview
SectionPreviewView(
sectionType: sectionType,
section: LayoutSection(buttons: buttons),
isLandscape: viewModel.isPreviewingLandscape,
fontStyle: viewModel.currentLayout.globalSettings.fontStyle,
buttonBackground: viewModel.currentLayout.globalSettings.buttonBackground,
theme: viewModel.currentLayout.globalSettings.theme
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
// Orientation toggle
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)
.listRowBackground(Color.clear)
// Added buttons
Section {
ForEach(buttons) { button in
NavigationLink {
ButtonConfigurationView(
buttonID: button.id,
sectionType: sectionType,
viewModel: viewModel
)
} label: {
ButtonRow(configuration: button)
}
}
.onMove { source, destination in
// Update local state immediately
buttons.move(fromOffsets: source, toOffset: destination)
// Sync to view model
viewModel.moveButtonSync(
fromOffsets: source,
toOffset: destination,
in: sectionType
)
}
.onDelete { indexSet in
// Get button IDs before removing from local state
let buttonIDs = indexSet.map { buttons[$0].id }
// Update local state immediately
buttons.remove(atOffsets: indexSet)
// Sync to view model
for id in buttonIDs {
viewModel.removeButtonSync(id, from: sectionType)
}
// Update available types
syncAvailableTypes()
}
} header: {
Text(String(localized: "settings.playerControls.addedButtons"))
}
// Available buttons
Section {
ForEach(availableTypes, id: \.self) { buttonType in
Button {
addButton(buttonType)
} label: {
HStack {
Image(systemName: buttonType.systemImage)
.frame(width: 24)
.foregroundStyle(.tint)
Text(buttonType.displayName)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "plus.circle.fill")
.foregroundStyle(.tint)
}
}
.buttonStyle(.plain)
}
} header: {
Text(String(localized: "settings.playerControls.availableButtons"))
} footer: {
Text(String(localized: "settings.playerControls.availableButtonsFooter"))
}
}
.navigationTitle(sectionTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
#if os(iOS)
EditButton()
#endif
}
.onAppear {
syncFromViewModel()
}
.onChange(of: viewModel.activePreset?.layout) { _, _ in
syncFromViewModel()
}
}
// MARK: - Helpers
private func syncFromViewModel() {
guard let preset = viewModel.activePreset else { return }
switch sectionType {
case .top:
buttons = preset.layout.topSection.buttons
case .bottom:
buttons = preset.layout.bottomSection.buttons
}
syncAvailableTypes()
}
private func syncAvailableTypes() {
let usedTypes = Set(buttons.map(\.buttonType))
availableTypes = ControlButtonType.availableForHorizontalSections.filter { buttonType in
// Spacer can be added multiple times
buttonType == .spacer || !usedTypes.contains(buttonType)
}
}
private func addButton(_ buttonType: ControlButtonType) {
// Create new config and add to local state immediately
let config = ControlButtonConfiguration.defaultConfiguration(for: buttonType)
buttons.append(config)
// Update available types
syncAvailableTypes()
// Sync to view model
viewModel.addButtonSync(buttonType, to: sectionType)
}
private var sectionTitle: String {
switch sectionType {
case .top:
return String(localized: "settings.playerControls.topButtons")
case .bottom:
return String(localized: "settings.playerControls.bottomButtons")
}
}
}
// MARK: - Button Row
private struct ButtonRow: View {
let configuration: ControlButtonConfiguration
var body: some View {
HStack(spacing: 12) {
Image(systemName: configuration.buttonType.systemImage)
.frame(width: 24)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(configuration.buttonType.displayName)
if configuration.visibilityMode != .both {
Text(configuration.visibilityMode.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
}
// MARK: - Section Preview
private struct SectionPreviewView: View {
let sectionType: LayoutSectionType
let section: LayoutSection
let isLandscape: Bool
let fontStyle: ControlsFontStyle
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
private let buttonSize: CGFloat = 20
private let buttonSpacing: CGFloat = 8
private let barHeight: CGFloat = 50
var body: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
if isLandscape {
// Landscape: use full container width, or scroll if content is wider
ScrollView(.horizontal, showsIndicators: false) {
barContent(minWidth: containerWidth, height: barHeight)
}
.frame(height: barHeight)
} else {
// Portrait: full width horizontal bar
barContent(minWidth: containerWidth, height: barHeight)
}
}
.frame(height: barHeight)
.padding(.horizontal)
.padding(.vertical, 8)
.modifier(PreviewThemeModifier(theme: theme))
}
@ViewBuilder
private func barContent(minWidth: CGFloat, height: CGFloat) -> some View {
HStack(spacing: buttonSpacing) {
ForEach(section.visibleButtons(isWideLayout: isLandscape)) { button in
PreviewButtonView(
configuration: button,
size: buttonSize,
isLandscape: isLandscape,
fontStyle: fontStyle,
buttonBackground: buttonBackground
)
}
}
.padding(.horizontal, 12)
.frame(minWidth: minWidth, minHeight: height)
.background(
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.overlay {
LinearGradient(
colors: sectionType == .top
? [.black.opacity(0.7), .clear]
: [.clear, .black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
}
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
private struct PreviewButtonView: View {
let configuration: ControlButtonConfiguration
let size: CGFloat
let isLandscape: Bool
let fontStyle: ControlsFontStyle
let buttonBackground: ButtonBackgroundStyle
/// Whether this button should show a glass background.
private var hasBackground: Bool { buttonBackground.glassStyle != nil }
/// Frame size - slightly larger when backgrounds are enabled.
private var frameSize: CGFloat { hasBackground ? size * 1.7 : size * 1.5 }
var body: some View {
Group {
if configuration.buttonType == .spacer {
if let settings = configuration.settings,
case .spacer(let spacerSettings) = settings,
spacerSettings.isFlexible {
Spacer()
} else {
Rectangle()
.fill(Color.clear)
.frame(width: spacerWidth)
}
} else if configuration.buttonType == .timeDisplay {
Text("0:00 / 3:45")
.font(fontStyle.font(size: size * 0.6, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
} else if configuration.buttonType == .brightness || configuration.buttonType == .volume {
// Slider buttons - show based on behavior
sliderPreview
} else if configuration.buttonType == .seek {
// Seek button - show dynamic icon based on settings
seekButtonContent
} else if configuration.buttonType == .titleAuthor {
// Title/Author button - show preview matching actual layout
titleAuthorPreview
} else {
// Regular button
regularButtonContent
}
}
}
// MARK: - Seek Button Content
@ViewBuilder
private var seekButtonContent: some View {
let seekSettings = configuration.seekSettings ?? SeekSettings()
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: seekSettings.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: seekSettings.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
// MARK: - Title/Author Preview
@ViewBuilder
private var titleAuthorPreview: some View {
let settings = configuration.titleAuthorSettings ?? TitleAuthorSettings()
let avatarSize = size * 1.4
HStack(spacing: 6) {
// Avatar placeholder
if settings.showSourceImage {
Circle()
.fill(.white.opacity(0.3))
.frame(width: avatarSize, height: avatarSize)
.overlay {
Text("Y")
.font(.system(size: size * 0.7, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
}
}
// Title and author stack
if settings.showTitle || settings.showSourceName {
VStack(alignment: .leading, spacing: 1) {
if settings.showTitle {
Text("Video Title")
.font(fontStyle.font(size: size * 0.55, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
}
if settings.showSourceName {
Text("Channel Name")
.font(fontStyle.font(size: size * 0.45))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, buttonBackground.glassStyle != nil ? 8 : 0)
.padding(.vertical, buttonBackground.glassStyle != nil ? 4 : 0)
.modifier(OptionalCapsuleGlassBackgroundModifier(style: buttonBackground))
}
// MARK: - Regular Button Content
@ViewBuilder
private var regularButtonContent: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
// MARK: - Slider Preview
@ViewBuilder
private var sliderPreview: some View {
let behavior = configuration.sliderSettings?.sliderBehavior ?? .expandOnTap
// Compute effective behavior based on orientation for autoExpandInLandscape
let effectiveBehavior: SliderBehavior = {
if behavior == .autoExpandInLandscape {
return isLandscape ? .alwaysVisible : .expandOnTap
}
return behavior
}()
HStack(spacing: 3) {
// Icon with optional glass background
sliderIconContent
// Fake slider - show when effectively always visible
if effectiveBehavior == .alwaysVisible {
ZStack(alignment: .leading) {
// Slider track
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.3))
.frame(width: size * 3, height: 3)
// Slider fill (showing ~60% filled)
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.8))
.frame(width: size * 1.8, height: 3)
}
}
}
}
@ViewBuilder
private var sliderIconContent: some View {
let iconFrameSize = hasBackground ? size * 1.4 : size * 1.2
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: iconFrameSize, height: iconFrameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: size))
.foregroundStyle(.white.opacity(0.9))
.frame(width: iconFrameSize, height: iconFrameSize)
}
}
private var spacerWidth: CGFloat {
guard let settings = configuration.settings,
case .spacer(let spacerSettings) = settings else {
return 8
}
return CGFloat(spacerSettings.fixedWidth) / 4
}
}
// MARK: - Optional Capsule Glass Background Modifier
/// A view modifier that conditionally applies a capsule glass background.
private struct OptionalCapsuleGlassBackgroundModifier: ViewModifier {
let style: ButtonBackgroundStyle
func body(content: Content) -> some View {
if let glassStyle = style.glassStyle {
content.glassBackground(glassStyle, in: .capsule, fallback: .ultraThinMaterial)
} else {
content
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
SectionEditorView(
sectionType: .top,
viewModel: PlayerControlsSettingsViewModel(
layoutService: PlayerControlsLayoutService(),
settingsManager: SettingsManager()
)
)
}
}