Files
yattee/Yattee/Views/Settings/PlayerControls/SectionEditorView.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

540 lines
19 KiB
Swift

//
// 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] = []
@State private var controlBarTheme: ControlsTheme = .system
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; macOS has no portrait/landscape distinction
#if os(iOS)
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)
#endif
// The macOS bar draws a glass capsule whose appearance can be forced.
#if os(macOS)
if sectionType == .bottom {
Section {
Picker(
String(localized: "settings.playerControls.glassBackground", defaultValue: "Glass Background"),
selection: $controlBarTheme
) {
ForEach(ControlsTheme.allCases, id: \.self) { theme in
Text(glassBackgroundLabel(for: theme)).tag(theme)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: controlBarTheme) { _, newTheme in
guard newTheme != viewModel.currentLayout.globalSettings.controlBarTheme else { return }
viewModel.updateGlobalSettingsSync { $0.controlBarTheme = newTheme }
}
} header: {
Text(String(localized: "settings.playerControls.appearance"))
}
}
#endif
// Added buttons
Section {
ForEach(buttons) { button in
NavigationLink {
ButtonConfigurationView(
buttonID: button.id,
sectionType: sectionType,
viewModel: viewModel
)
} label: {
ButtonRow(configuration: button)
}
.contextMenu {
if viewModel.canEditActivePreset {
Button(role: .destructive) {
removeButton(id: button.id)
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
}
}
.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
let buttonIDs = indexSet.map { buttons[$0].id }
for id in buttonIDs {
removeButton(id: id)
}
}
} 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
.opaqueSettingsFormBackground()
.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
}
controlBarTheme = preset.layout.globalSettings.controlBarTheme
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 removeButton(id: UUID) {
// Update local state immediately
buttons.removeAll { $0.id == id }
// Sync to view model
viewModel.removeButtonSync(id, from: sectionType)
// Update available types
syncAvailableTypes()
}
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)
}
#if os(macOS)
private func glassBackgroundLabel(for theme: ControlsTheme) -> String {
switch theme {
case .system:
return String(localized: "controls.theme.auto", defaultValue: "Auto")
case .light, .dark:
return theme.displayName
}
}
#endif
private var sectionTitle: String {
switch sectionType {
case .top:
return String(localized: "settings.playerControls.topButtons")
case .bottom:
#if os(macOS)
return String(localized: "settings.playerControls.controlBar", defaultValue: "Control Bar")
#else
return String(localized: "settings.playerControls.bottomButtons")
#endif
}
}
}
// 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 os(iOS)
if configuration.visibilityMode != .both {
Text(configuration.visibilityMode.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
#endif
}
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(verbatim: "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(verbatim: "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(verbatim: "Video Title")
.font(fontStyle.font(size: size * 0.55, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
}
if settings.showSourceName {
Text(verbatim: "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()
)
)
}
}