mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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
540 lines
19 KiB
Swift
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()
|
|
)
|
|
)
|
|
}
|
|
}
|