Drive macOS player control bars from presets

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.
This commit is contained in:
Arkadiusz Fal
2026-05-31 16:55:39 +02:00
parent fa6ad2235c
commit 05bbd449f8
18 changed files with 1348 additions and 404 deletions

View File

@@ -36,7 +36,9 @@ struct ButtonConfigurationView: View {
var body: some View {
if let config = configuration {
Form {
// Visibility mode (all buttons)
// 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"),
@@ -54,15 +56,26 @@ struct ButtonConfigurationView: View {
} 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
.onAppear {
syncFromConfiguration(config)

View File

@@ -20,8 +20,28 @@ struct CenterControlsSettingsView: View {
@State private var leftSlider: SideSliderType = .disabled
@State private var rightSlider: SideSliderType = .disabled
/// On macOS the center overlay doesn't exist; the seek durations always
/// apply (keyboard arrows and default seek buttons), so the controls are
/// always visible there.
private var seekBackwardControlsVisible: Bool {
#if os(macOS)
return true
#else
return showSeekBackward
#endif
}
private var seekForwardControlsVisible: Bool {
#if os(macOS)
return true
#else
return showSeekForward
#endif
}
var body: some View {
Form {
#if os(iOS)
// Preview
Section {
CenterPreviewView(
@@ -47,9 +67,11 @@ struct CenterControlsSettingsView: View {
} header: {
Text(String(localized: "settings.playerControls.center.playback"))
}
#endif
// Seek backward settings
Section {
#if os(iOS)
Toggle(
String(localized: "settings.playerControls.center.showSeekBackward"),
isOn: $showSeekBackward
@@ -57,8 +79,9 @@ struct CenterControlsSettingsView: View {
.onChange(of: showSeekBackward) { _, newValue in
viewModel.updateCenterSettingsSync { $0.showSeekBackward = newValue }
}
#endif
if showSeekBackward {
if seekBackwardControlsVisible {
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.center.seekBackwardTime"))
@@ -97,6 +120,7 @@ struct CenterControlsSettingsView: View {
// Seek forward settings
Section {
#if os(iOS)
Toggle(
String(localized: "settings.playerControls.center.showSeekForward"),
isOn: $showSeekForward
@@ -104,8 +128,9 @@ struct CenterControlsSettingsView: View {
.onChange(of: showSeekForward) { _, newValue in
viewModel.updateCenterSettingsSync { $0.showSeekForward = newValue }
}
#endif
if showSeekForward {
if seekForwardControlsVisible {
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.center.seekForwardTime"))
@@ -140,6 +165,13 @@ struct CenterControlsSettingsView: View {
}
} header: {
Text(String(localized: "settings.playerControls.center.seekForward"))
} footer: {
#if os(macOS)
Text(String(
localized: "settings.playerControls.center.seekDurationsFooter",
defaultValue: "Seek durations are used by the ← and → keyboard shortcuts and by seek buttons added to the player."
))
#endif
}
#if os(iOS)
@@ -175,7 +207,12 @@ struct CenterControlsSettingsView: View {
}
#endif
}
#if os(macOS)
.formStyle(.grouped)
.navigationTitle(String(localized: "settings.playerControls.seekDurations", defaultValue: "Seek Durations"))
#else
.navigationTitle(String(localized: "settings.playerControls.centerControls"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif

View File

@@ -30,6 +30,8 @@ struct MiniPlayerEditorView: View {
.navigationTitle(String(localized: "settings.playerControls.miniPlayer"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.onAppear {
syncLocalState()
@@ -274,6 +276,8 @@ struct MiniPlayerButtonConfigurationView: View {
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.onAppear {
syncFromConfiguration(config)

View File

@@ -74,7 +74,10 @@ private struct PlayerControlsSettingsContent: View {
var body: some View {
Form {
// The phone-shaped preview and comments pill don't apply to the macOS player.
#if os(iOS)
PreviewSection(viewModel: viewModel, layout: previewLayout)
#endif
PresetSection(
viewModel: viewModel,
trackedPresetName: $trackedActivePresetName,
@@ -87,13 +90,16 @@ private struct PlayerControlsSettingsContent: View {
fontStyle: $fontStyle
)
LayoutSectionsSection(viewModel: viewModel)
CommentsPillSection(viewModel: viewModel)
#if os(iOS)
CommentsPillSection(viewModel: viewModel)
GesturesSectionsSection(viewModel: viewModel)
#endif
SystemControlsSection(viewModel: viewModel)
VolumeSection(viewModel: viewModel)
}
#if os(macOS)
.formStyle(.grouped)
#endif
.id(refreshID)
.alert(
String(localized: "settings.playerControls.error"),
@@ -237,6 +243,8 @@ private struct AppearanceSection: View {
var body: some View {
Section {
// The macOS bar draws its own glass capsule; the per-button style has no effect there.
#if os(iOS)
Picker(
String(localized: "settings.playerControls.style"),
selection: $style
@@ -250,6 +258,7 @@ private struct AppearanceSection: View {
guard newStyle != viewModel.currentLayout.globalSettings.style else { return }
viewModel.updateGlobalSettingsSync { $0.style = newStyle }
}
#endif
Picker(
String(localized: "settings.playerControls.buttonSize"),
@@ -312,10 +321,17 @@ private struct LayoutSectionsSection: View {
NavigationLink {
CenterControlsSettingsView(viewModel: viewModel)
} label: {
#if os(macOS)
Label(
String(localized: "settings.playerControls.seekDurations", defaultValue: "Seek Durations"),
systemImage: "arrow.trianglehead.clockwise"
)
#else
Label(
String(localized: "settings.playerControls.centerControls"),
systemImage: "play.circle"
)
#endif
}
.disabled(!viewModel.canEditActivePreset)
@@ -336,10 +352,17 @@ private struct LayoutSectionsSection: View {
)
} label: {
HStack {
#if os(macOS)
Label(
String(localized: "settings.playerControls.controlBarButtons", defaultValue: "Control Bar Buttons"),
systemImage: "rectangle.bottomthird.inset.filled"
)
#else
Label(
String(localized: "settings.playerControls.bottomButtons"),
systemImage: "rectangle.bottomthird.inset.filled"
)
#endif
Spacer()
Text("\(viewModel.currentLayout.bottomSection.buttons.count)")
.foregroundStyle(.secondary)
@@ -347,6 +370,8 @@ private struct LayoutSectionsSection: View {
}
.disabled(!viewModel.canEditActivePreset)
// The player pill only exists in the iOS player.
#if os(iOS)
NavigationLink {
PlayerPillEditorView(viewModel: viewModel)
} label: {
@@ -361,6 +386,7 @@ private struct LayoutSectionsSection: View {
}
}
.disabled(!viewModel.canEditActivePreset)
#endif
NavigationLink {
MiniPlayerEditorView(viewModel: viewModel)

View File

@@ -85,39 +85,22 @@ struct PresetEditorView: View {
}
var body: some View {
#if os(macOS)
macOSDialog
#else
NavigationStack {
Form {
// Base Layout Picker (only for create mode)
if case .create(_, _) = mode {
Section {
Picker(
String(localized: "settings.playerControls.baseLayout"),
selection: $selectedBaseLayoutID
) {
ForEach(baseLayouts) { preset in
Text(preset.name).tag(preset.id as UUID?)
}
}
basePicker
}
}
Section {
TextField(mode.placeholder, text: $name)
.focused($isNameFocused)
.submitLabel(.done)
.onSubmit(saveIfValid)
nameField
} footer: {
HStack {
if trimmedName.count > LayoutPreset.maxNameLength {
Text(String(localized: "settings.playerControls.nameTooLong"))
.foregroundStyle(.red)
}
Spacer()
Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
.foregroundStyle(
trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary
)
}
nameFooter
}
}
.navigationTitle(mode.title)
@@ -145,11 +128,87 @@ struct PresetEditorView: View {
#if os(iOS)
.presentationDetents([.medium])
#endif
#if os(macOS)
.frame(minWidth: 500, minHeight: 350)
#endif
}
// MARK: - Shared Controls
private var basePicker: some View {
Picker(
String(localized: "settings.playerControls.baseLayout"),
selection: $selectedBaseLayoutID
) {
ForEach(baseLayouts) { preset in
Text(preset.name).tag(preset.id as UUID?)
}
}
}
private var nameField: some View {
TextField(mode.placeholder, text: $name)
.focused($isNameFocused)
.submitLabel(.done)
.onSubmit(saveIfValid)
}
private var nameFooter: some View {
HStack {
if trimmedName.count > LayoutPreset.maxNameLength {
Text(String(localized: "settings.playerControls.nameTooLong"))
.foregroundStyle(.red)
}
Spacer()
Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
.foregroundStyle(
trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary
)
}
}
// MARK: - macOS Dialog
#if os(macOS)
/// Compact dialog layout matching native macOS sheets (title, fields,
/// trailing action buttons) instead of the iOS navigation-bar form.
private var macOSDialog: some View {
VStack(alignment: .leading, spacing: 16) {
Text(mode.title)
.font(.headline)
if case .create(_, _) = mode {
basePicker
}
VStack(alignment: .leading, spacing: 4) {
nameField
.textFieldStyle(.roundedBorder)
nameFooter
.font(.caption)
}
HStack {
Spacer()
Button(String(localized: "settings.playerControls.cancel")) {
dismiss()
}
.keyboardShortcut(.cancelAction)
Button(mode.saveButtonTitle) {
saveIfValid()
}
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
}
.padding(20)
.frame(width: 400)
.onAppear {
isNameFocused = true
}
}
#endif
private func saveIfValid() {
guard isValid else { return }
onSave(trimmedName, selectedBaseLayoutID)

View File

@@ -29,6 +29,8 @@ struct ProgressBarSettingsView: View {
.navigationTitle(String(localized: "settings.playerControls.progressBar"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.onAppear {
loadSettings()

View File

@@ -30,7 +30,8 @@ struct SectionEditorView: View {
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
// Orientation toggle
// Orientation toggle; macOS has no portrait/landscape distinction
#if os(iOS)
Picker(
String(localized: "settings.playerControls.previewOrientation"),
selection: $viewModel.isPreviewingLandscape
@@ -42,6 +43,7 @@ struct SectionEditorView: View {
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
#endif
// Added buttons
Section {
@@ -182,11 +184,13 @@ private struct ButtonRow: View {
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()