diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 0b50fee..08c92c9 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -345,6 +345,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() Defaults.Keys.migrateMusicAuxControls() + Defaults.Keys.migrateMusicControlSlots() // Initialize idle animations (load bundled + built-in face) idleAnimationManager.initializeDefaultAnimations() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 6f83d09..aaf1b75 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2838,6 +2838,10 @@ } } }, + "Allow media output control in layouts" : { + "comment" : "A toggle that enables media output control in layouts.", + "isCommentAutoGenerated" : true + }, "ALPHA" : { "comment" : "A label indicating a feature that is still in development.", "isCommentAutoGenerated" : true @@ -5326,6 +5330,10 @@ "comment" : "An accessibility label for the button that clears the search field in the sidebar.", "isCommentAutoGenerated" : true }, + "Clear slot" : { + "comment" : "A label for a button that clears a music control slot.", + "isCommentAutoGenerated" : true + }, "Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : { "localizations" : { "fr" : { @@ -5840,6 +5848,10 @@ } } }, + "Control Palette" : { + "comment" : "A section header in the music slot configuration view, describing the available controls.", + "isCommentAutoGenerated" : true + }, "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app." : { }, @@ -7343,6 +7355,14 @@ } } }, + "Drag a control onto a slot or tap to place it in the first empty slot." : { + "comment" : "A description below the control palette that explains how users can interact with it.", + "isCommentAutoGenerated" : true + }, + "Drag items between slots or drop from the palette to remap controls." : { + "comment" : "A description of how to interact with the music slot configuration view.", + "isCommentAutoGenerated" : true + }, "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible." : { }, @@ -7835,6 +7855,10 @@ "comment" : "A toggle that enables or disables the custom OSD feature.", "isCommentAutoGenerated" : true }, + "Enable customizable controls" : { + "comment" : "A toggle that allows users to enable or disable customizable controls for media playback.", + "isCommentAutoGenerated" : true + }, "Enable global keyboard shortcuts above to customize your shortcuts." : { }, @@ -10862,6 +10886,9 @@ "Layout" : { "comment" : "A label for the segment control that lets the user choose the layout of the weather widget.", "isCommentAutoGenerated" : true + }, + "Layout Preview" : { + }, "License key" : { "localizations" : { @@ -15801,6 +15828,10 @@ } } }, + "Reset to defaults" : { + "comment" : "A button that resets the music slot configuration to its default values.", + "isCommentAutoGenerated" : true + }, "Reset Width" : { "comment" : "A button to reset the expanded notch width to its default value.", "isCommentAutoGenerated" : true @@ -17158,10 +17189,6 @@ "comment" : "A toggle that shows or hides the full event titles in the calendar view", "isCommentAutoGenerated" : true }, - "Show media output control in other layouts" : { - "comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.", - "isCommentAutoGenerated" : true - }, "Show on a specific display" : { "localizations" : { "ar" : { @@ -17370,6 +17397,7 @@ }, "Show shuffle and repeat buttons" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -19131,6 +19159,10 @@ } } }, + "Turn on customizable controls to rearrange media buttons." : { + "comment" : "A description of the feature that allows users to rearrange the order of media buttons.", + "isCommentAutoGenerated" : true + }, "Turn this off to include all-day entries in the notch calendar and reminder live activity." : { "comment" : "A description of why the user can disable the \"Hide all-day events\" toggle.", "isCommentAutoGenerated" : true diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 6299566..cf6d56d 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -27,10 +27,9 @@ struct LockScreenMusicPanel: View { @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelUsesBlur) var enableBlur - @Default(.showMediaOutputControl) var showMediaOutputControl - @Default(.showShuffleAndRepeat) var showShuffleAndRepeat - @Default(.musicAuxLeftControl) private var leftAuxControl - @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.showMediaOutputControl) private var showMediaOutputControl + @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat + @Default(.musicControlSlots) private var slotConfig @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics @@ -362,72 +361,115 @@ struct LockScreenMusicPanel: View { } private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { - let controls = resolvedAuxControls let skipNudge: CGFloat = isExpanded ? 14 : 9 - let seekInterval: TimeInterval = 10 - - let backwardConfig: (icon: String, interaction: PanelControlButton.Interaction, symbol: PanelControlButton.SymbolEffectStyle, action: () -> Void) - let forwardConfig: (icon: String, interaction: PanelControlButton.Interaction, symbol: PanelControlButton.SymbolEffectStyle, action: () -> Void) - - switch musicSkipBehavior { - case .track: - backwardConfig = ( - icon: "backward.fill", - interaction: .nudge(-skipNudge), - symbol: .replace, - action: { musicManager.previousTrack() } - ) - forwardConfig = ( - icon: "forward.fill", - interaction: .nudge(skipNudge), - symbol: .replace, - action: { musicManager.nextTrack() } - ) - case .tenSecond: - backwardConfig = ( - icon: "gobackward.10", - interaction: .wiggle(.counterClockwise), - symbol: .wiggle, - action: { musicManager.seek(by: -seekInterval) } - ) - forwardConfig = ( - icon: "goforward.10", - interaction: .wiggle(.clockwise), - symbol: .wiggle, - action: { musicManager.seek(by: seekInterval) } - ) - } return HStack(spacing: spacing) { - if let leftControl = controls.left { - auxButton(for: leftControl) - } - - controlButton( - icon: backwardConfig.icon, - size: 18, - interaction: backwardConfig.interaction, - symbolEffect: backwardConfig.symbol, - action: backwardConfig.action - ) - - playPauseButton - - controlButton( - icon: forwardConfig.icon, - size: 18, - interaction: forwardConfig.interaction, - symbolEffect: forwardConfig.symbol, - action: forwardConfig.action - ) - - if let rightControl = controls.right { - auxButton(for: rightControl) + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot, skipNudge: skipNudge) } } .frame(maxWidth: .infinity, alignment: alignment) } + private var displayedSlots: [MusicControlButton] { + guard showShuffleAndRepeat else { + return fallbackSlots + } + + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + private var fallbackSlots: [MusicControlButton] { + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] + } + } + + @ViewBuilder + private func slotView(for control: MusicControlButton, skipNudge: CGFloat) -> some View { + let seekInterval: TimeInterval = 10 + + switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + playPauseButton + case .trackBackward: + controlButton( + icon: "backward.fill", + size: 18, + interaction: .nudge(-skipNudge), + symbolEffect: .replace + ) { + musicManager.previousTrack() + } + case .trackForward: + controlButton( + icon: "forward.fill", + size: 18, + interaction: .nudge(skipNudge), + symbolEffect: .replace + ) { + musicManager.nextTrack() + } + case .seekBackward: + controlButton( + icon: "gobackward.10", + size: 18, + interaction: .wiggle(.counterClockwise), + symbolEffect: .wiggle + ) { + musicManager.seek(by: -seekInterval) + } + case .seekForward: + controlButton( + icon: "goforward.10", + size: 18, + interaction: .wiggle(.clockwise), + symbolEffect: .wiggle + ) { + musicManager.seek(by: seekInterval) + } + case .shuffle: + controlButton( + icon: "shuffle", + size: 18, + isActive: musicManager.isShuffled, + activeColor: .red + ) { + musicManager.toggleShuffle() + } + case .repeatMode: + controlButton( + icon: repeatIcon, + size: 18, + isActive: musicManager.repeatMode != .off, + activeColor: .red, + symbolEffect: .replace + ) { + musicManager.toggleRepeat() + } + case .mediaOutput: + mediaOutputControlButton + case .lyrics: + controlButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + size: 18, + isActive: enableLyrics, + activeColor: .accentColor, + symbolEffect: .replace + ) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + enableLyrics.toggle() + } + } + } + } + private var playPauseButton: some View { let frameSize: CGFloat = isExpanded ? 80 : 54 let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill" @@ -520,62 +562,6 @@ struct LockScreenMusicPanel: View { .animation(.smooth(duration: 0.32), value: line) } - private var availableAuxControls: [MusicAuxiliaryControl] { - MusicAuxiliaryControl.allCases.filter { control in - control != .mediaOutput || showMediaOutputControl - } - } - - private var resolvedAuxControls: (left: MusicAuxiliaryControl?, right: MusicAuxiliaryControl?) { - guard showShuffleAndRepeat else { return (nil, nil) } - - let options = availableAuxControls - guard !options.isEmpty else { return (nil, nil) } - - let left = options.contains(leftAuxControl) ? leftAuxControl : options.first! - var rightCandidate = options.contains(rightAuxControl) ? rightAuxControl : options.first! - - if options.count == 1 { - return (left, nil) - } - - if rightCandidate == left, let alternative = options.first(where: { $0 != left }) { - rightCandidate = alternative - } - - if rightCandidate == left { - return (left, nil) - } - - return (left, rightCandidate) - } - - @ViewBuilder - private func auxButton(for control: MusicAuxiliaryControl) -> some View { - switch control { - case .shuffle: - controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { - musicManager.toggleShuffle() - } - case .repeatMode: - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - musicManager.toggleRepeat() - } - case .mediaOutput: - mediaOutputControlButton - case .lyrics: - controlButton( - icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", - isActive: enableLyrics, - activeColor: .accentColor - ) { - withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { - enableLyrics.toggle() - } - } - } - } - private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 7f2a5d2..77fe179 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -17,8 +17,9 @@ import AppKit struct MinimalisticMusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID - @Default(.musicAuxLeftControl) private var leftAuxControl - @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.showShuffleAndRepeat) private var showCustomControls + @Default(.musicControlSlots) private var slotConfig + @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.musicSkipBehavior) private var musicSkipBehavior @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @ObservedObject private var timerManager = TimerManager.shared @@ -26,6 +27,8 @@ struct MinimalisticMusicPlayerView: View { @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableLyrics) private var enableLyrics @Default(.timerPresets) private var timerPresets + private let seekInterval: TimeInterval = 10 + private let skipMagnitude: CGFloat = 8 var body: some View { VStack(spacing: 0) { @@ -646,67 +649,9 @@ private struct MinimalisticReminderDetailsView: View { // MARK: - Playback Controls (Larger) private var playbackControls: some View { - let controls = resolvedAuxControls - let seekInterval: TimeInterval = 10 - let skipPressMagnitude: CGFloat = 8 - - let backwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void) - let forwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void) - - switch musicSkipBehavior { - case .track: - backwardConfig = ( - icon: "backward.fill", - press: .nudge(-skipPressMagnitude), - symbol: .replace, - action: { musicManager.previousTrack() } - ) - forwardConfig = ( - icon: "forward.fill", - press: .nudge(skipPressMagnitude), - symbol: .replace, - action: { musicManager.nextTrack() } - ) - case .tenSecond: - backwardConfig = ( - icon: "gobackward.10", - press: .wiggle(.counterClockwise), - symbol: .wiggle, - action: { musicManager.seek(by: -seekInterval) } - ) - forwardConfig = ( - icon: "goforward.10", - press: .wiggle(.clockwise), - symbol: .wiggle, - action: { musicManager.seek(by: seekInterval) } - ) - } - - return HStack(spacing: 16) { - if Defaults[.showShuffleAndRepeat] { - auxButton(for: controls.left) - } - - controlButton( - icon: backwardConfig.icon, - size: 18, - pressEffect: backwardConfig.press, - symbolEffect: backwardConfig.symbol, - action: backwardConfig.action - ) - - playPauseButton - - controlButton( - icon: forwardConfig.icon, - size: 18, - pressEffect: forwardConfig.press, - symbolEffect: forwardConfig.symbol, - action: forwardConfig.action - ) - - if Defaults[.showShuffleAndRepeat] { - auxButton(for: controls.right) + HStack(spacing: 16) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) } } .frame(maxWidth: .infinity, alignment: .center) @@ -751,9 +696,59 @@ private struct MinimalisticReminderDetailsView: View { ) } + private var displayedSlots: [MusicControlButton] { + if showCustomControls { + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] + } + } + @ViewBuilder - private func auxButton(for control: MusicAuxiliaryControl) -> some View { + private func slotView(for control: MusicControlButton) -> some View { switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + playPauseButton + case .trackBackward: + controlButton( + icon: "backward.fill", + size: 18, + pressEffect: .nudge(-skipMagnitude), + symbolEffect: .replace, + action: { musicManager.previousTrack() } + ) + case .trackForward: + controlButton( + icon: "forward.fill", + size: 18, + pressEffect: .nudge(skipMagnitude), + symbolEffect: .replace, + action: { musicManager.nextTrack() } + ) + case .seekBackward: + controlButton( + icon: "gobackward.10", + size: 18, + pressEffect: .wiggle(.counterClockwise), + symbolEffect: .wiggle, + action: { musicManager.seek(by: -seekInterval) } + ) + case .seekForward: + controlButton( + icon: "goforward.10", + size: 18, + pressEffect: .wiggle(.clockwise), + symbolEffect: .wiggle, + action: { musicManager.seek(by: seekInterval) } + ) case .shuffle: controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { Task { await musicManager.toggleShuffle() } @@ -775,13 +770,6 @@ private struct MinimalisticReminderDetailsView: View { } } } - - private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) { - guard leftAuxControl == rightAuxControl else { - return (leftAuxControl, rightAuxControl) - } - return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl)) - } private struct MinimalisticMediaOutputButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 2d14d73..3e7001f 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -15,12 +15,11 @@ import SwiftUI struct MusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID - let showShuffleAndRepeat: Bool var body: some View { HStack(alignment: .top, spacing: 12) { AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) - MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat) + MusicControlsView() .frame(maxWidth: .infinity, alignment: .leading) .drawingGroup() .compositingGroup() @@ -119,11 +118,13 @@ struct MusicControlsView: View { @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast - let showShuffleAndRepeat: Bool - @Default(.musicAuxLeftControl) private var leftAuxControl - @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.showShuffleAndRepeat) private var showCustomControls + @Default(.musicControlSlots) private var slotConfig + @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics + private let seekInterval: TimeInterval = 10 + private let skipMagnitude: CGFloat = 6 var body: some View { VStack(alignment: .leading) { @@ -214,65 +215,9 @@ struct MusicControlsView: View { } private var playbackControls: some View { - let controls = resolvedAuxControls - let seekInterval: TimeInterval = 10 - let skipMagnitude: CGFloat = 6 - - let backwardConfig: (icon: String, press: HoverButton.PressEffect?, action: () -> Void) - let forwardConfig: (icon: String, press: HoverButton.PressEffect?, action: () -> Void) - - switch musicSkipBehavior { - case .track: - backwardConfig = ( - icon: "backward.fill", - press: .nudge(-skipMagnitude), - action: { musicManager.previousTrack() } - ) - forwardConfig = ( - icon: "forward.fill", - press: .nudge(skipMagnitude), - action: { musicManager.nextTrack() } - ) - case .tenSecond: - backwardConfig = ( - icon: "gobackward.10", - press: .wiggle(.counterClockwise), - action: { musicManager.seek(by: -seekInterval) } - ) - forwardConfig = ( - icon: "goforward.10", - press: .wiggle(.clockwise), - action: { musicManager.seek(by: seekInterval) } - ) - } - - return HStack(spacing: 8) { - if showShuffleAndRepeat { - auxButton(for: controls.left) - } - - HoverButton( - icon: backwardConfig.icon, - scale: .medium, - pressEffect: backwardConfig.press - ) { - backwardConfig.action() - } - - HoverButton(icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", scale: .large) { - MusicManager.shared.togglePlay() - } - - HoverButton( - icon: forwardConfig.icon, - scale: .medium, - pressEffect: forwardConfig.press - ) { - forwardConfig.action() - } - - if showShuffleAndRepeat { - auxButton(for: controls.right) + HStack(spacing: 8) { + ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in + slotView(for: slot) } } .frame(maxWidth: .infinity, alignment: .center) @@ -298,16 +243,48 @@ struct MusicControlsView: View { } } - private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) { - guard leftAuxControl == rightAuxControl else { - return (leftAuxControl, rightAuxControl) + private var displayedSlots: [MusicControlButton] { + if showCustomControls { + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) + return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout + } + + switch musicSkipBehavior { + case .track: + return MusicControlButton.minimalLayout + case .tenSecond: + return [.none, .seekBackward, .playPause, .seekForward, .none] } - return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl)) } @ViewBuilder - private func auxButton(for control: MusicAuxiliaryControl) -> some View { + private func slotView(for control: MusicControlButton) -> some View { switch control { + case .none: + Spacer(minLength: 0) + case .playPause: + HoverButton( + icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", + scale: .large + ) { + MusicManager.shared.togglePlay() + } + case .trackBackward: + playbackButton(icon: "backward.fill", press: .nudge(-skipMagnitude)) { + musicManager.previousTrack() + } + case .trackForward: + playbackButton(icon: "forward.fill", press: .nudge(skipMagnitude)) { + musicManager.nextTrack() + } + case .seekBackward: + playbackButton(icon: "gobackward.10", press: .wiggle(.counterClockwise)) { + musicManager.seek(by: -seekInterval) + } + case .seekForward: + playbackButton(icon: "goforward.10", press: .wiggle(.clockwise)) { + musicManager.seek(by: seekInterval) + } case .shuffle: HoverButton( icon: "shuffle", @@ -336,6 +313,20 @@ struct MusicControlsView: View { } } } + + private func playbackButton( + icon: String, + press: HoverButton.PressEffect?, + action: @escaping () -> Void + ) -> some View { + HoverButton( + icon: icon, + scale: .medium, + pressEffect: press + ) { + action() + } + } } // MARK: - Main View @@ -363,7 +354,7 @@ struct NotchHomeView: View { MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) } else { // Normal mode: Show full music player with optional calendar and webcam - MusicPlayerView(albumArtNamespace: albumArtNamespace, showShuffleAndRepeat: Defaults[.showShuffleAndRepeat]) + MusicPlayerView(albumArtNamespace: albumArtNamespace) .frame(maxWidth: .infinity, alignment: .leading) if Defaults[.showCalendar] { diff --git a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift new file mode 100644 index 0000000..33a98cc --- /dev/null +++ b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift @@ -0,0 +1,272 @@ +import Defaults +import SwiftUI +import UniformTypeIdentifiers + +struct MusicSlotConfigurationView: View { + @Default(.musicControlSlots) private var musicControlSlots + @Default(.showMediaOutputControl) private var showMediaOutputControl + @ObservedObject private var musicManager = MusicManager.shared + + private let slotCount = MusicControlButton.slotCount + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + layoutPreview + Divider() + palette + resetButton + } + .onAppear { + ensureSlotCapacity(slotCount) + removeDisallowedControls() + } + .onChange(of: showMediaOutputControl) { _, _ in + removeDisallowedControls() + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Layout Preview") + .font(.headline) + Text("Drag items between slots or drop from the palette to remap controls.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var layoutPreview: some View { + HStack(alignment: .center, spacing: 12) { + HStack(spacing: 8) { + ForEach(0.. some View { + let slot = slotValue(at: index) + return Group { + if slot != .none { + slotPreview(for: slot) + .frame(width: 48, height: 48) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .onDrag { + NSItemProvider(object: NSString(string: "slot:\(index)")) + } + .onDrop(of: [UTType.plainText.identifier], isTargeted: nil) { providers in + handleDrop(providers, toIndex: index) + } + } else { + slotPreview(for: slot) + .frame(width: 48, height: 48) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .onDrop(of: [UTType.plainText.identifier], isTargeted: nil) { providers in + handleDrop(providers, toIndex: index) + } + } + } + } + + @ViewBuilder + private func slotPreview(for slot: MusicControlButton) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + + if slot == .none { + RoundedRectangle(cornerRadius: 6) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .foregroundStyle(.secondary.opacity(0.35)) + .frame(width: 26, height: 26) + } else { + Image(systemName: slot.iconName) + .font(.system(size: slot.prefersLargeScale ? 18 : 15, weight: .medium)) + .foregroundStyle(previewIconColor(for: slot)) + } + } + } + + private func paletteItem(for control: MusicControlButton) -> some View { + VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + .frame(width: 44, height: 44) + .overlay { + Image(systemName: control.iconName) + .font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium)) + .foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary) + } + .onDrag { + NSItemProvider(object: NSString(string: "control:\(control.rawValue)")) + } + .onTapGesture { + place(control) + } + + Text(control.label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 72) + .multilineTextAlignment(.center) + } + .opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1) + .disabled(control == .mediaOutput && !showMediaOutputControl) + } + + private func place(_ control: MusicControlButton) { + if let emptyIndex = musicControlSlots.firstIndex(of: .none) { + updateSlot(control, at: emptyIndex) + } else { + updateSlot(control, at: 0) + } + } + + private func slotValue(at index: Int) -> MusicControlButton { + let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl) + guard normalized.indices.contains(index) else { return .none } + return normalized[index] + } + + private var pickerOptions: [MusicControlButton] { + let base = MusicControlButton.pickerOptions + if showMediaOutputControl { + return base + } + return base.filter { $0 != .mediaOutput } + } + + private func previewIconColor(for slot: MusicControlButton) -> Color { + switch slot { + case .shuffle: + return musicManager.isShuffled ? .red : .primary + case .repeatMode: + return musicManager.repeatMode == .off ? .primary : .red + case .lyrics: + return Defaults[.enableLyrics] ? .accentColor : .primary + default: + return .primary + } + } + + private func ensureSlotCapacity(_ target: Int) { + guard target > musicControlSlots.count else { return } + let padding = target - musicControlSlots.count + musicControlSlots.append(contentsOf: Array(repeating: .none, count: padding)) + } + + private func handleDrop(_ providers: [NSItemProvider], toIndex: Int) -> Bool { + for provider in providers where provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let raw = item as? String else { return } + DispatchQueue.main.async { + processDropString(raw, toIndex: toIndex) + } + } + return true + } + return false + } + + private func handleDropOnTrash(_ providers: [NSItemProvider]) -> Bool { + for provider in providers where provider.canLoadObject(ofClass: NSString.self) { + provider.loadObject(ofClass: NSString.self) { item, _ in + guard let raw = item as? String else { return } + DispatchQueue.main.async { + if raw.hasPrefix("slot:"), let index = Int(raw.dropFirst(5)) { + clearSlot(at: index) + } + } + } + return true + } + return false + } + + private func processDropString(_ raw: String, toIndex: Int) { + if raw.hasPrefix("slot:"), let source = Int(raw.dropFirst(5)) { + swapSlot(from: source, to: toIndex) + } else if raw.hasPrefix("control:"), let control = MusicControlButton(rawValue: String(raw.dropFirst(8))) { + updateSlot(control, at: toIndex) + } + } + + private func clearSlot(at index: Int) { + guard index >= 0 && index < musicControlSlots.count else { return } + musicControlSlots[index] = .none + } + + private func swapSlot(from source: Int, to destination: Int) { + guard source != destination else { return } + ensureSlotCapacity(max(source, destination) + 1) + musicControlSlots.swapAt(source, destination) + } + + private func updateSlot(_ value: MusicControlButton, at index: Int) { + ensureSlotCapacity(index + 1) + var current = musicControlSlots + current[index] = value + musicControlSlots = current + } + + private func removeDisallowedControls() { + if showMediaOutputControl { return } + let filtered = musicControlSlots.map { $0 == .mediaOutput ? .none : $0 } + musicControlSlots = filtered + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 53319b9..d6f0da5 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1360,13 +1360,9 @@ struct Media: View { @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat - @Default(.musicAuxLeftControl) private var musicAuxLeftControl - @Default(.musicAuxRightControl) private var musicAuxRightControl @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.musicControlWindowEnabled) private var musicControlWindowEnabled @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget - @State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl] - @State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl] private func highlightID(_ title: String) -> String { SettingsTab.media.highlightID(for: title) @@ -1408,16 +1404,21 @@ struct Media: View { Section { Defaults.Toggle(key: .showShuffleAndRepeat) { HStack { - Text("Show shuffle and repeat buttons") + Text("Enable customizable controls") customBadge(text: "Beta") } } Defaults.Toggle(key: .showMediaOutputControl) { - Text("Show media output control in other layouts") + Text("Allow media output control in layouts") } .disabled(!showShuffleAndRepeat) if showShuffleAndRepeat { - mediaControlsGrid + MusicSlotConfigurationView() + } else { + Text("Turn on customizable controls to rearrange media buttons.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 4) } } header: { Text("Media controls") @@ -1512,142 +1513,9 @@ struct Media: View { Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never } } - .onAppear { - ensureAuxControlsUnique() - previousLeftAuxControl = musicAuxLeftControl - previousRightAuxControl = musicAuxRightControl - } - .onChange(of: musicAuxLeftControl) { newValue in - if newValue == musicAuxRightControl { - let fallback = MusicAuxiliaryControl.alternative( - excluding: newValue, - preferring: previousLeftAuxControl - ) - if fallback != musicAuxRightControl { - musicAuxRightControl = fallback - } - } - - previousLeftAuxControl = newValue - } - .onChange(of: musicAuxRightControl) { newValue in - if newValue == musicAuxLeftControl { - let fallback = MusicAuxiliaryControl.alternative( - excluding: newValue, - preferring: previousRightAuxControl - ) - if fallback != musicAuxLeftControl { - musicAuxLeftControl = fallback - } - } - - previousRightAuxControl = newValue - } - .onChange(of: showShuffleAndRepeat) { isEnabled in - if isEnabled { - ensureAuxControlsUnique() - } - } .navigationTitle("Media") } - private var mediaControlsGrid: some View { - VStack(alignment: .leading, spacing: 16) { - mediaControlsRow(title: "Left button", selection: $musicAuxLeftControl, otherSelection: musicAuxRightControl) - mediaControlsRow(title: "Right button", selection: $musicAuxRightControl, otherSelection: musicAuxLeftControl) - } - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func mediaControlsRow(title: String, selection: Binding, otherSelection: MusicAuxiliaryControl) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white) - - HStack(spacing: 12) { - ForEach([MusicAuxiliaryControl.mediaOutput, MusicAuxiliaryControl.repeatMode, MusicAuxiliaryControl.lyrics, MusicAuxiliaryControl.shuffle], id: \.self) { control in - MediaControlButton( - control: control, - isSelected: selection.wrappedValue == control, - disabled: control == otherSelection - ) { - selection.wrappedValue = control - } - } - } - } - } - - struct MediaControlButton: View { - let control: MusicAuxiliaryControl - let isSelected: Bool - let disabled: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - VStack(spacing: 8) { - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) - ) - - Image(systemName: control.symbolName) - .font(.system(size: 22, weight: .medium)) - .foregroundStyle(iconColor) - } - .frame(width: 68, height: 52) - .onHover { hovering in - if !disabled { - withAnimation(.easeInOut(duration: 0.2)) { - isHovering = hovering - } - } - } - - Text(control.displayName) - .font(.caption) - .fontWeight(.medium) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(width: 80) - .foregroundStyle(disabled ? .secondary : .primary) - } - .contentShape(Rectangle()) - .onTapGesture { - if !disabled { - action() - } - } - .opacity(disabled ? 0.5 : 1.0) - } - - private var backgroundColor: Color { - if disabled { return Color(nsColor: .controlBackgroundColor) } - if isSelected { return Color.accentColor.opacity(0.1) } - if isHovering { return Color.primary.opacity(0.05) } - return Color(nsColor: .controlBackgroundColor) - } - - private var borderColor: Color { - if isSelected { return Color.accentColor } - if isHovering { return Color.primary.opacity(0.1) } - return Color.clear - } - - private var iconColor: Color { - if disabled { return .secondary } - if isSelected { return .accentColor } - return .primary - } - } - // Only show controller options that are available on this macOS version private var availableMediaControllers: [MediaControllerType] { if MusicManager.shared.isNowPlayingDeprecated { @@ -1656,15 +1524,6 @@ struct Media: View { return MediaControllerType.allCases } } - - private func ensureAuxControlsUnique() { - guard showShuffleAndRepeat, musicAuxLeftControl == musicAuxRightControl else { return } - - let fallback = MusicAuxiliaryControl.alternative(excluding: musicAuxLeftControl) - if fallback != musicAuxRightControl { - musicAuxRightControl = fallback - } - } } struct CalendarSettings: View { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 2320c2e..4a51459 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -447,6 +447,8 @@ extension Defaults.Keys { static let musicAuxLeftControl = Key("musicAuxLeftControl", default: .shuffle) static let musicAuxRightControl = Key("musicAuxRightControl", default: .repeatMode) static let didMigrateMusicAuxControls = Key("didMigrateMusicAuxControls", default: false) + static let musicControlSlots = Key<[MusicControlButton]>("musicControlSlots", default: MusicControlButton.defaultLayout) + static let didMigrateMusicControlSlots = Key("didMigrateMusicControlSlots", default: false) static let musicSkipBehavior = Key("musicSkipBehavior", default: .track) static let musicControlWindowEnabled = Key("musicControlWindowEnabled", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) @@ -668,6 +670,27 @@ extension Defaults.Keys { normalizeMusicAuxControls() } + static func migrateMusicControlSlots() { + guard Defaults[.didMigrateMusicControlSlots] == false else { return } + + let allowMediaOutput = Defaults[.showMediaOutputControl] + let baseLayout: [MusicControlButton] + + if Defaults[.showShuffleAndRepeat] { + var slots = MusicControlButton.defaultLayout + let left = MusicControlButton(auxiliaryControl: Defaults[.musicAuxLeftControl]) + let right = MusicControlButton(auxiliaryControl: Defaults[.musicAuxRightControl]) + slots[0] = left + slots[4] = right + baseLayout = slots + } else { + baseLayout = MusicControlButton.minimalLayout + } + + Defaults[.musicControlSlots] = baseLayout.normalized(allowingMediaOutput: allowMediaOutput) + Defaults[.didMigrateMusicControlSlots] = true + } + private static func normalizeMusicAuxControls() { guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return } diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift new file mode 100644 index 0000000..f403afa --- /dev/null +++ b/DynamicIsland/models/MusicControlButton.swift @@ -0,0 +1,136 @@ +import Defaults + +enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.Serializable { + case shuffle + case trackBackward + case playPause + case trackForward + case repeatMode + case mediaOutput + case lyrics + case seekBackward + case seekForward + case none + + static let slotCount = 5 + + static let defaultLayout: [MusicControlButton] = [ + .shuffle, + .trackBackward, + .playPause, + .trackForward, + .repeatMode + ] + + static let minimalLayout: [MusicControlButton] = [ + .none, + .trackBackward, + .playPause, + .trackForward, + .none + ] + + static let pickerOptions: [MusicControlButton] = [ + .trackBackward, + .playPause, + .trackForward, + .seekBackward, + .seekForward, + .shuffle, + .repeatMode, + .lyrics, + .mediaOutput + ] + + var id: String { rawValue } + + var label: String { + switch self { + case .shuffle: + return "Shuffle" + case .trackBackward: + return "Previous Track" + case .playPause: + return "Play / Pause" + case .trackForward: + return "Next Track" + case .repeatMode: + return "Repeat" + case .mediaOutput: + return "Media Output" + case .lyrics: + return "Lyrics" + case .seekBackward: + return "Rewind 10s" + case .seekForward: + return "Forward 10s" + case .none: + return "Empty Slot" + } + } + + var iconName: String { + switch self { + case .shuffle: + return "shuffle" + case .trackBackward: + return "backward.fill" + case .playPause: + return "playpause" + case .trackForward: + return "forward.fill" + case .repeatMode: + return "repeat" + case .mediaOutput: + return "speaker.wave.2" + case .lyrics: + return "quote.bubble" + case .seekBackward: + return "gobackward.10" + case .seekForward: + return "goforward.10" + case .none: + return "" + } + } + + var prefersLargeScale: Bool { + self == .playPause + } +} + +extension Array where Element == MusicControlButton { + func normalized(allowingMediaOutput: Bool) -> [MusicControlButton] { + var sanitized = map { button -> MusicControlButton in + if button == .mediaOutput && !allowingMediaOutput { + return .none + } + return button + } + + if sanitized.count < MusicControlButton.slotCount { + sanitized.append(contentsOf: Array(repeating: .none, count: MusicControlButton.slotCount - sanitized.count)) + } + + if sanitized.count > MusicControlButton.slotCount { + sanitized = Array(sanitized.prefix(MusicControlButton.slotCount)) + } + + return sanitized + } +} + +extension MusicControlButton { + init(auxiliaryControl: MusicAuxiliaryControl) { + switch auxiliaryControl { + case .shuffle: + self = .shuffle + case .repeatMode: + self = .repeatMode + case .mediaOutput: + self = .mediaOutput + case .lyrics: + self = .lyrics + } + } +}