Files
yattee/Yattee/Views/Settings/PlayerControls/ButtonConfigurationView.swift
Arkadiusz Fal 9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00

398 lines
14 KiB
Swift

//
// 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); portrait/wide modes have no
// meaning on macOS, where every button is always shown.
#if os(iOS)
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"))
}
#endif
// Type-specific settings
if config.buttonType.hasSettings {
typeSpecificSettings(for: config)
}
#if os(macOS)
if !config.buttonType.hasSettings {
Section {
Text(String(localized: "settings.playerControls.noButtonSettings"))
.foregroundStyle(.secondary)
}
}
#endif
}
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// 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()
)
)
}
}