mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Remapping functionality
This commit is contained in:
parent
d451ea34fd
commit
96dde9d555
9 changed files with 687 additions and 399 deletions
|
|
@ -345,6 +345,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Migrate legacy progress bar settings
|
// Migrate legacy progress bar settings
|
||||||
Defaults.Keys.migrateProgressBarStyle()
|
Defaults.Keys.migrateProgressBarStyle()
|
||||||
Defaults.Keys.migrateMusicAuxControls()
|
Defaults.Keys.migrateMusicAuxControls()
|
||||||
|
Defaults.Keys.migrateMusicControlSlots()
|
||||||
|
|
||||||
// Initialize idle animations (load bundled + built-in face)
|
// Initialize idle animations (load bundled + built-in face)
|
||||||
idleAnimationManager.initializeDefaultAnimations()
|
idleAnimationManager.initializeDefaultAnimations()
|
||||||
|
|
|
||||||
|
|
@ -2838,6 +2838,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Allow media output control in layouts" : {
|
||||||
|
"comment" : "A toggle that enables media output control in layouts.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"ALPHA" : {
|
"ALPHA" : {
|
||||||
"comment" : "A label indicating a feature that is still in development.",
|
"comment" : "A label indicating a feature that is still in development.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -5326,6 +5330,10 @@
|
||||||
"comment" : "An accessibility label for the button that clears the search field in the sidebar.",
|
"comment" : "An accessibility label for the button that clears the search field in the sidebar.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"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." : {
|
"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." : {
|
"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.",
|
"comment" : "A toggle that enables or disables the custom OSD feature.",
|
||||||
"isCommentAutoGenerated" : true
|
"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." : {
|
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -10862,6 +10886,9 @@
|
||||||
"Layout" : {
|
"Layout" : {
|
||||||
"comment" : "A label for the segment control that lets the user choose the layout of the weather widget.",
|
"comment" : "A label for the segment control that lets the user choose the layout of the weather widget.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Layout Preview" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"License key" : {
|
"License key" : {
|
||||||
"localizations" : {
|
"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" : {
|
"Reset Width" : {
|
||||||
"comment" : "A button to reset the expanded notch width to its default value.",
|
"comment" : "A button to reset the expanded notch width to its default value.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -17158,10 +17189,6 @@
|
||||||
"comment" : "A toggle that shows or hides the full event titles in the calendar view",
|
"comment" : "A toggle that shows or hides the full event titles in the calendar view",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Show on a specific display" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -17370,6 +17397,7 @@
|
||||||
|
|
||||||
},
|
},
|
||||||
"Show shuffle and repeat buttons" : {
|
"Show shuffle and repeat buttons" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"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." : {
|
"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.",
|
"comment" : "A description of why the user can disable the \"Hide all-day events\" toggle.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,9 @@ struct LockScreenMusicPanel: View {
|
||||||
@Default(.lockScreenShowAppIcon) var showAppIcon
|
@Default(.lockScreenShowAppIcon) var showAppIcon
|
||||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||||
@Default(.showShuffleAndRepeat) var showShuffleAndRepeat
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
@Default(.musicAuxLeftControl) private var leftAuxControl
|
@Default(.musicControlSlots) private var slotConfig
|
||||||
@Default(.musicAuxRightControl) private var rightAuxControl
|
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
@Default(.enableLyrics) private var enableLyrics
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
|
|
||||||
|
|
@ -362,72 +361,115 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||||
let controls = resolvedAuxControls
|
|
||||||
let skipNudge: CGFloat = isExpanded ? 14 : 9
|
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) {
|
return HStack(spacing: spacing) {
|
||||||
if let leftControl = controls.left {
|
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
|
||||||
auxButton(for: leftControl)
|
slotView(for: slot, skipNudge: skipNudge)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.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 {
|
private var playPauseButton: some View {
|
||||||
let frameSize: CGFloat = isExpanded ? 80 : 54
|
let frameSize: CGFloat = isExpanded ? 80 : 54
|
||||||
let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill"
|
let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
|
@ -520,62 +562,6 @@ struct LockScreenMusicPanel: View {
|
||||||
.animation(.smooth(duration: 0.32), value: line)
|
.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 {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
case .off: return "repeat"
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ import AppKit
|
||||||
struct MinimalisticMusicPlayerView: View {
|
struct MinimalisticMusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
@Default(.musicAuxLeftControl) private var leftAuxControl
|
@Default(.showShuffleAndRepeat) private var showCustomControls
|
||||||
@Default(.musicAuxRightControl) private var rightAuxControl
|
@Default(.musicControlSlots) private var slotConfig
|
||||||
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||||||
@ObservedObject private var timerManager = TimerManager.shared
|
@ObservedObject private var timerManager = TimerManager.shared
|
||||||
|
|
@ -26,6 +27,8 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||||||
@Default(.enableLyrics) private var enableLyrics
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
@Default(.timerPresets) private var timerPresets
|
@Default(.timerPresets) private var timerPresets
|
||||||
|
private let seekInterval: TimeInterval = 10
|
||||||
|
private let skipMagnitude: CGFloat = 8
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -646,67 +649,9 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
// MARK: - Playback Controls (Larger)
|
// MARK: - Playback Controls (Larger)
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
let controls = resolvedAuxControls
|
HStack(spacing: 16) {
|
||||||
let seekInterval: TimeInterval = 10
|
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
|
||||||
let skipPressMagnitude: CGFloat = 8
|
slotView(for: slot)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.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
|
@ViewBuilder
|
||||||
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
private func slotView(for control: MusicControlButton) -> some View {
|
||||||
switch control {
|
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:
|
case .shuffle:
|
||||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||||
Task { await musicManager.toggleShuffle() }
|
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 {
|
private struct MinimalisticMediaOutputButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,11 @@ import SwiftUI
|
||||||
struct MusicPlayerView: View {
|
struct MusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
let showShuffleAndRepeat: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
|
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
|
||||||
MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat)
|
MusicControlsView()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.drawingGroup()
|
.drawingGroup()
|
||||||
.compositingGroup()
|
.compositingGroup()
|
||||||
|
|
@ -119,11 +118,13 @@ struct MusicControlsView: View {
|
||||||
@State private var sliderValue: Double = 0
|
@State private var sliderValue: Double = 0
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
let showShuffleAndRepeat: Bool
|
@Default(.showShuffleAndRepeat) private var showCustomControls
|
||||||
@Default(.musicAuxLeftControl) private var leftAuxControl
|
@Default(.musicControlSlots) private var slotConfig
|
||||||
@Default(.musicAuxRightControl) private var rightAuxControl
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
@Default(.enableLyrics) private var enableLyrics
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
|
private let seekInterval: TimeInterval = 10
|
||||||
|
private let skipMagnitude: CGFloat = 6
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|
@ -214,65 +215,9 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
let controls = resolvedAuxControls
|
HStack(spacing: 8) {
|
||||||
let seekInterval: TimeInterval = 10
|
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
|
||||||
let skipMagnitude: CGFloat = 6
|
slotView(for: slot)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
@ -298,16 +243,48 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
private var displayedSlots: [MusicControlButton] {
|
||||||
guard leftAuxControl == rightAuxControl else {
|
if showCustomControls {
|
||||||
return (leftAuxControl, rightAuxControl)
|
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
|
@ViewBuilder
|
||||||
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
private func slotView(for control: MusicControlButton) -> some View {
|
||||||
switch control {
|
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:
|
case .shuffle:
|
||||||
HoverButton(
|
HoverButton(
|
||||||
icon: "shuffle",
|
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
|
// MARK: - Main View
|
||||||
|
|
@ -363,7 +354,7 @@ struct NotchHomeView: View {
|
||||||
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
|
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: Show full music player with optional calendar and webcam
|
// 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)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
if Defaults[.showCalendar] {
|
if Defaults[.showCalendar] {
|
||||||
|
|
|
||||||
|
|
@ -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..<slotCount, id: \.self) { index in
|
||||||
|
slotPreview(for: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.onDrop(of: [UTType.plainText.identifier], isTargeted: nil) { providers in
|
||||||
|
handleDropOnTrash(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Clear slot")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var palette: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Control Palette")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Drag a control onto a slot or tap to place it in the first empty slot.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: true) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(pickerOptions, id: \.self) { control in
|
||||||
|
paletteItem(for: control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resetButton: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Reset to defaults") {
|
||||||
|
withAnimation {
|
||||||
|
musicControlSlots = MusicControlButton.defaultLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slotPreview(for index: Int) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1360,13 +1360,9 @@ struct Media: View {
|
||||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
|
|
||||||
@Default(.musicAuxRightControl) private var musicAuxRightControl
|
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
|
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
|
||||||
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
@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 {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.media.highlightID(for: title)
|
SettingsTab.media.highlightID(for: title)
|
||||||
|
|
@ -1408,16 +1404,21 @@ struct Media: View {
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle(key: .showShuffleAndRepeat) {
|
Defaults.Toggle(key: .showShuffleAndRepeat) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Show shuffle and repeat buttons")
|
Text("Enable customizable controls")
|
||||||
customBadge(text: "Beta")
|
customBadge(text: "Beta")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Defaults.Toggle(key: .showMediaOutputControl) {
|
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||||
Text("Show media output control in other layouts")
|
Text("Allow media output control in layouts")
|
||||||
}
|
}
|
||||||
.disabled(!showShuffleAndRepeat)
|
.disabled(!showShuffleAndRepeat)
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
mediaControlsGrid
|
MusicSlotConfigurationView()
|
||||||
|
} else {
|
||||||
|
Text("Turn on customizable controls to rearrange media buttons.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Media controls")
|
Text("Media controls")
|
||||||
|
|
@ -1512,142 +1513,9 @@ struct Media: View {
|
||||||
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
|
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")
|
.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<MusicAuxiliaryControl>, 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
|
// Only show controller options that are available on this macOS version
|
||||||
private var availableMediaControllers: [MediaControllerType] {
|
private var availableMediaControllers: [MediaControllerType] {
|
||||||
if MusicManager.shared.isNowPlayingDeprecated {
|
if MusicManager.shared.isNowPlayingDeprecated {
|
||||||
|
|
@ -1656,15 +1524,6 @@ struct Media: View {
|
||||||
return MediaControllerType.allCases
|
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 {
|
struct CalendarSettings: View {
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,8 @@ extension Defaults.Keys {
|
||||||
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
|
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
|
||||||
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
|
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
|
||||||
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
|
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
|
||||||
|
static let musicControlSlots = Key<[MusicControlButton]>("musicControlSlots", default: MusicControlButton.defaultLayout)
|
||||||
|
static let didMigrateMusicControlSlots = Key<Bool>("didMigrateMusicControlSlots", default: false)
|
||||||
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
|
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
|
||||||
static let musicControlWindowEnabled = Key<Bool>("musicControlWindowEnabled", default: false)
|
static let musicControlWindowEnabled = Key<Bool>("musicControlWindowEnabled", default: false)
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
|
|
@ -668,6 +670,27 @@ extension Defaults.Keys {
|
||||||
normalizeMusicAuxControls()
|
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() {
|
private static func normalizeMusicAuxControls() {
|
||||||
guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return }
|
guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return }
|
||||||
|
|
||||||
|
|
|
||||||
136
DynamicIsland/models/MusicControlButton.swift
Normal file
136
DynamicIsland/models/MusicControlButton.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue