mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Buttons in the section, mini player, and pill editors can now be removed via long-press/right-click context menu instead of only swipe-to-delete. This also gives macOS a delete affordance in the section editor, which previously had none.
539 lines
19 KiB
Swift
539 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
|
|
.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()
|
|
)
|
|
)
|
|
}
|
|
}
|