mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
372
Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift
Normal file
372
Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
208
Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift
Normal file
208
Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift
Normal 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
|
||||
170
Yattee/Views/Settings/PlayerControls/PresetEditorView.swift
Normal file
170
Yattee/Views/Settings/PlayerControls/PresetEditorView.swift
Normal 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 }
|
||||
}
|
||||
504
Yattee/Views/Settings/PlayerControls/PresetSelectorView.swift
Normal file
504
Yattee/Views/Settings/PlayerControls/PresetSelectorView.swift
Normal 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
479
Yattee/Views/Settings/PlayerControls/SectionEditorView.swift
Normal file
479
Yattee/Views/Settings/PlayerControls/SectionEditorView.swift
Normal 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user