mirror of
https://github.com/yattee/yattee.git
synced 2026-07-22 15:22:01 +00:00
Render the macOS control bar button row and top bar from the active preset's sections via a new MacOSControlsSectionRenderer, replacing the hardcoded QuickTime-style rows in MacOSControlBar. Add a keepOnTop button type and macOS-specific button availability lists, and adapt the player controls settings editors (preset editor, section editor, button configuration) for macOS. Also apply the preset font style to the control bar time labels, and update the built-in macOS Default preset order (queue before transport, fullscreen at the end), bumping builtInPresetsVersion to 7.
484 lines
17 KiB
Swift
484 lines
17 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] = []
|
|
|
|
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
|
|
|
|
// 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 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()
|
|
)
|
|
)
|
|
}
|
|
}
|