Merge pull request #175 from Ebullioscopic/feature/media

Remappability Updates and BugFixes
This commit is contained in:
Hariharan Mudaliar 2025-12-14 13:46:55 +05:30 • committed by GitHub
commit 031186e81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 783 additions and 423 deletions

View file

@ -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()

View file

@ -5326,6 +5326,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 +5844,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 +7351,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 +7851,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." : {
},
@ -8854,6 +8874,10 @@
},
"Fit to Notch" : {
},
"Floating window panel skip behaviour" : {
"comment" : "A section header that describes the floating window panel's skip behaviour settings.",
"isCommentAutoGenerated" : true
},
"Focus active: %@" : {
"comment" : "An accessibility label for the focus widget, describing the currently active focus mode.",
@ -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" : {
@ -17469,6 +17497,10 @@
}
}
},
"Show sneak peek on playback changes" : {
"comment" : "A toggle that enables showing sneak peek when the currently playing track changes.",
"isCommentAutoGenerated" : true
},
"Show the active Focus name inside the indicator." : {
"comment" : "A description of why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.",
"isCommentAutoGenerated" : true
@ -17502,10 +17534,6 @@
}
}
},
"Skip behaviour" : {
"comment" : "A section header that describes the skip behaviour settings.",
"isCommentAutoGenerated" : true
},
"Skip buttons" : {
"comment" : "A section header that lists the skip behaviour options.",
"isCommentAutoGenerated" : true
@ -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

View file

@ -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"

View file

@ -19,6 +19,10 @@ class AudioSpectrum: NSView {
setupBars()
}
deinit {
animationTimer?.invalidate()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
wantsLayer = true
@ -50,6 +54,15 @@ class AudioSpectrum: NSView {
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
stopAnimating()
} else if isPlaying {
startAnimating()
}
}
private func startAnimating() {
guard animationTimer == nil else { return }
animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in
@ -108,6 +121,10 @@ struct AudioSpectrumView: NSViewRepresentable {
func updateNSView(_ nsView: AudioSpectrum, context: Context) {
nsView.setPlaying(isPlaying)
}
static func dismantleNSView(_ nsView: AudioSpectrum, coordinator: ()) {
nsView.setPlaying(false)
}
}
#Preview {

View file

@ -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()

View file

@ -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] {

View file

@ -0,0 +1,323 @@
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
@State private var hoveredSlotIndex: Int? = nil
@State private var targetedSlotIndex: Int? = nil
@State private var trashDropIsTargeted: Bool = false
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: .top, 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(trashDropIsTargeted ? Color.red.opacity(0.2) : Color(nsColor: .controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(trashDropIsTargeted ? Color.red : .clear, lineWidth: trashDropIsTargeted ? 2 : 0)
)
.frame(width: 56, height: 56)
Image(systemName: "trash")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(trashDropIsTargeted ? Color.red : Color.primary)
}
.onDrop(of: [UTType.plainText.identifier], isTargeted: $trashDropIsTargeted) { providers in
return handleDropOnTrash(providers)
}
Text("Clear slot")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
.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)
let isHovered = hoveredSlotIndex == index
let isDropTarget = targetedSlotIndex == index
return ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(slotBackgroundColor(isHovered: isHovered, isTargeted: isDropTarget))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(slotBorderColor(isHovered: isHovered, isTargeted: isDropTarget), lineWidth: borderWidth(isHovered: isHovered, isTargeted: isDropTarget))
)
slotContent(for: slot)
}
.frame(width: 48, height: 48)
.contentShape(RoundedRectangle(cornerRadius: 10))
.onHover { isInside in
withAnimation(.easeInOut(duration: 0.12)) {
hoveredSlotIndex = isInside ? index : nil
}
}
.onDrag {
hoveredSlotIndex = index
return NSItemProvider(object: NSString(string: "slot:\(index)"))
}
.onDrop(of: [UTType.plainText.identifier], isTargeted: dropTargetBinding(for: index)) { providers in
return handleDrop(providers, toIndex: index)
}
}
@ViewBuilder
private func slotContent(for slot: MusicControlButton) -> some View {
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)
.lineLimit(1)
.minimumScaleFactor(0.8)
.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
}
private func slotBackgroundColor(isHovered: Bool, isTargeted: Bool) -> Color {
if isTargeted {
return Color.accentColor.opacity(0.25)
} else if isHovered {
return Color.accentColor.opacity(0.15)
}
return Color(nsColor: .controlBackgroundColor)
}
private func slotBorderColor(isHovered: Bool, isTargeted: Bool) -> Color {
if isTargeted {
return Color.accentColor
} else if isHovered {
return Color.accentColor.opacity(0.8)
}
return .clear
}
private func borderWidth(isHovered: Bool, isTargeted: Bool) -> CGFloat {
if isTargeted { return 2 }
if isHovered { return 1.5 }
return 0
}
private func dropTargetBinding(for index: Int) -> Binding<Bool> {
Binding(
get: { targetedSlotIndex == index },
set: { newValue in
if newValue {
targetedSlotIndex = index
} else if targetedSlotIndex == index {
targetedSlotIndex = nil
}
}
)
}
}

View file

@ -1360,13 +1360,10 @@ 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]
@Default(.showSneakPeekOnTrackChange) private var showSneakPeekOnTrackChange
private func highlightID(_ title: String) -> String {
SettingsTab.media.highlightID(for: title)
@ -1408,34 +1405,37 @@ 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")
}
.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")
}
Section {
Picker("Skip buttons", selection: $musicSkipBehavior) {
ForEach(MusicSkipBehavior.allCases) { behavior in
Text(behavior.displayName).tag(behavior)
if musicControlWindowEnabled {
Section {
Picker("Skip buttons", selection: $musicSkipBehavior) {
ForEach(MusicSkipBehavior.allCases) { behavior in
Text(behavior.displayName).tag(behavior)
}
}
}
.pickerStyle(.segmented)
.settingsHighlight(id: highlightID("Skip buttons"))
.pickerStyle(.segmented)
.settingsHighlight(id: highlightID("Skip buttons"))
Text(musicSkipBehavior.description)
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Skip behaviour")
Text(musicSkipBehavior.description)
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Floating window panel skip behaviour")
}
}
Section {
Toggle(
@ -1449,6 +1449,8 @@ struct Media: View {
.disabled(!coordinator.musicLiveActivityEnabled)
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
Toggle("Show sneak peek on playback changes", isOn: $showSneakPeekOnTrackChange)
.disabled(!enableSneakPeek)
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
.settingsHighlight(id: highlightID("Enable lyrics"))
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
@ -1512,142 +1514,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<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
private var availableMediaControllers: [MediaControllerType] {
if MusicManager.shared.isNowPlayingDeprecated {
@ -1656,15 +1525,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 {

View file

@ -220,14 +220,14 @@ class MusicManager: ObservableObject {
if eventIsPlaying != self.isPlaying {
let animation: Animation? = (expectedState == eventIsPlaying) ? .smooth(duration: 0.18) : .smooth
applyPlayState(eventIsPlaying, animation: animation)
if eventIsPlaying && !state.title.isEmpty && !state.artist.isEmpty {
self.updateSneakPeek()
}
} else {
self.updateIdleState(state: eventIsPlaying)
}
if eventIsPlaying && !state.title.isEmpty && !state.artist.isEmpty {
self.updateSneakPeek()
}
// Check for changes in track metadata using last artwork change values
let titleChanged = state.title != self.lastArtworkTitle
let artistChanged = state.artist != self.lastArtworkArtist
@ -239,6 +239,8 @@ class MusicManager: ObservableObject {
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged
// Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
if hasContentChange {
self.triggerFlipAnimation()
@ -263,7 +265,7 @@ class MusicManager: ObservableObject {
self.fetchLyrics()
// Only update sneak peek if there's actual content and something changed
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek()
}
}

View file

@ -440,6 +440,7 @@ extension Defaults.Keys {
static let coloredSpectrogram = Key<Bool>("coloredSpectrogram", default: true)
static let enableSneakPeek = Key<Bool>("enableSneakPeek", default: false)
static let sneakPeekStyles = Key<SneakPeekStyle>("sneakPeekStyles", default: .standard)
static let showSneakPeekOnTrackChange = Key<Bool>("showSneakPeekOnTrackChange", default: true)
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
static let waitInterval = Key<Double>("waitInterval", default: 3)
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: true)
@ -447,6 +448,8 @@ extension Defaults.Keys {
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
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 musicControlWindowEnabled = Key<Bool>("musicControlWindowEnabled", default: false)
// Enable lock screen media widget (shows the standalone panel when screen is locked)
@ -668,6 +671,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 }

View 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
}
}
}