Merge pull request #118 from Ebullioscopic/feature/media

Floating Media Controls
This commit is contained in:
Hariharan Mudaliar 2025-11-23 00:47:15 +05:30 • committed by GitHub
commit 7db3e09e83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 910 additions and 15 deletions

View file

@ -71,16 +71,42 @@ struct ContentView: View {
@State private var lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero
@State private var isMusicControlWindowVisible = false
@State private var pendingMusicControlTask: Task<Void, Never>?
@State private var musicControlHideTask: Task<Void, Never>?
@State private var musicControlVisibilityDeadline: Date?
@State private var isMusicControlWindowSuppressed = false
@State private var hasPendingMusicControlSync = false
@State private var pendingMusicControlForceRefresh = false
@State private var musicControlSuppressionTask: Task<Void, Never>?
@State private var haptics: Bool = false
@Namespace var albumArtNamespace
@Default(.useMusicVisualizer) var useMusicVisualizer
@Default(.musicControlWindowEnabled) var musicControlWindowEnabled
@Default(.showNotHumanFace) var showNotHumanFace
@Default(.useModernCloseAnimation) var useModernCloseAnimation
@Default(.enableMinimalisticUI) var enableMinimalisticUI
private static let musicControlLogFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter
}()
private func logMusicControlEvent(_ message: String) {
#if DEBUG
let timestamp = Self.musicControlLogFormatter.string(from: Date())
print("[MusicControl] \(timestamp): \(message)")
#endif
}
private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) {
guard !isMusicControlWindowVisible else { return }
enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
}
private var dynamicNotchResizeAnimation: Animation? {
if enableMinimalisticUI && reminderManager.activeWindowReminders.isEmpty == false {
return nil
@ -91,6 +117,8 @@ struct ContentView: View {
private let extendedHoverPadding: CGFloat = 30
private let zeroHeightHoverPadding: CGFloat = 10
private let statsAdditionalRowHeight: CGFloat = 110
private let musicControlPauseGrace: TimeInterval = 5
private let musicControlResumeDelay: TimeInterval = 0.24
// Use minimalistic corner radius ONLY when opened, keep normal when closed
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
@ -368,6 +396,95 @@ struct ContentView: View {
.background(dragDetector)
.environmentObject(vm)
.environmentObject(webcamManager)
.onAppear {
isMusicControlWindowSuppressed = vm.notchState != .closed || lockScreenManager.isLocked
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
}
.onChange(of: vm.notchState) { _, state in
if state == .open {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: musicControlWindowEnabled) { _, enabled in
if enabled {
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in
if enabled {
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: vm.hideOnClosed) { _, hidden in
if hidden {
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: lockScreenManager.isLocked) { _, locked in
if locked {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: gestureProgress) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: isHovering) { _, hovering in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12)
}
}
.onChange(of: musicManager.isPlaying) { _, isPlaying in
handleMusicControlPlaybackChange(isPlaying: isPlaying)
}
.onChange(of: musicManager.isPlayerIdle) { _, isIdle in
handleMusicControlIdleChange(isIdle: isIdle)
}
.onChange(of: vm.closedNotchSize) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onChange(of: vm.effectiveClosedNotchHeight) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onDisappear {
// Clean up all timer work items to prevent memory leaks and conflicts
hoverWorkItem?.cancel()
@ -375,6 +492,11 @@ struct ContentView: View {
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
cancelMusicControlWindowSync()
hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
clearMusicControlVisibilityDeadline()
musicControlSuppressionTask?.cancel()
}
}
@ -910,6 +1032,247 @@ struct ContentView: View {
}
}
}
private func handleMusicControlPlaybackChange(isPlaying: Bool) {
guard musicControlWindowEnabled else { return }
if isPlaying {
clearMusicControlVisibilityDeadline()
requestMusicControlWindowSyncIfHidden()
} else {
extendMusicControlVisibilityAfterPause()
}
}
private func handleMusicControlIdleChange(isIdle: Bool) {
guard musicControlWindowEnabled else { return }
if isIdle {
if musicControlVisibilityDeadline == nil {
extendMusicControlVisibilityAfterPause()
}
} else if musicManager.isPlaying {
clearMusicControlVisibilityDeadline()
}
}
private func extendMusicControlVisibilityAfterPause() {
let deadline = Date().addingTimeInterval(musicControlPauseGrace)
musicControlVisibilityDeadline = deadline
scheduleMusicControlVisibilityCheck(deadline: deadline)
requestMusicControlWindowSyncIfHidden()
}
private func clearMusicControlVisibilityDeadline() {
musicControlVisibilityDeadline = nil
cancelMusicControlVisibilityTimer()
}
private func scheduleMusicControlVisibilityCheck(deadline: Date) {
cancelMusicControlVisibilityTimer()
let interval = max(0, deadline.timeIntervalSinceNow)
musicControlHideTask = Task.detached(priority: .background) { [interval] in
if interval > 0 {
let nanoseconds = UInt64(interval * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if let currentDeadline = musicControlVisibilityDeadline, currentDeadline <= Date() {
musicControlVisibilityDeadline = nil
}
enqueueMusicControlWindowSync(forceRefresh: false)
musicControlHideTask = nil
}
}
}
private func cancelMusicControlVisibilityTimer() {
musicControlHideTask?.cancel()
musicControlHideTask = nil
}
private func musicControlVisibilityIsActive() -> Bool {
if musicManager.isPlaying {
return true
}
guard let deadline = musicControlVisibilityDeadline else { return false }
return Date() <= deadline
}
private func suppressMusicControlWindowUpdates() {
isMusicControlWindowSuppressed = true
musicControlSuppressionTask?.cancel()
musicControlSuppressionTask = nil
}
private func releaseMusicControlWindowUpdates(after delay: TimeInterval) {
musicControlSuppressionTask?.cancel()
musicControlSuppressionTask = Task { [delay] in
if delay > 0 {
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if vm.notchState == .closed && !lockScreenManager.isLocked {
isMusicControlWindowSuppressed = false
triggerPendingMusicControlSyncIfNeeded()
} else {
isMusicControlWindowSuppressed = true
}
musicControlSuppressionTask = nil
}
}
}
private func triggerPendingMusicControlSyncIfNeeded() {
guard hasPendingMusicControlSync else { return }
let shouldForce = pendingMusicControlForceRefresh
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
logMusicControlEvent("Flushing pending floating window sync (force: \(shouldForce))")
scheduleMusicControlWindowSync(forceRefresh: shouldForce, bypassSuppression: true)
}
private func shouldDeferMusicControlSync() -> Bool {
vm.notchState != .closed || lockScreenManager.isLocked || isMusicControlWindowSuppressed
}
private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
if shouldDeferMusicControlSync() {
hasPendingMusicControlSync = true
if forceRefresh {
pendingMusicControlForceRefresh = true
}
logMusicControlEvent("Queued floating window sync (force: \(forceRefresh)) while deferred")
return
}
logMusicControlEvent("Scheduling floating window sync (force: \(forceRefresh), delay: \(delay))")
scheduleMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
}
private func shouldShowMusicControlWindow() -> Bool {
guard musicControlWindowEnabled,
coordinator.musicLiveActivityEnabled,
vm.notchState == .closed,
!vm.hideOnClosed,
!lockScreenManager.isLocked,
!isMusicControlWindowSuppressed else {
return false
}
return musicControlVisibilityIsActive()
}
private func scheduleMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0, bypassSuppression: Bool = false) {
#if os(macOS)
cancelMusicControlWindowSync()
guard shouldShowMusicControlWindow() else {
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
hideMusicControlWindow()
return
}
if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) {
hasPendingMusicControlSync = true
if forceRefresh {
pendingMusicControlForceRefresh = true
}
return
}
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
let syncDelay = max(0, delay)
pendingMusicControlTask = Task.detached(priority: .userInitiated) { [forceRefresh, syncDelay] in
if syncDelay > 0 {
let nanoseconds = UInt64(syncDelay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
guard !Task.isCancelled else { return }
await MainActor.run {
if shouldShowMusicControlWindow() {
logMusicControlEvent("Running floating window sync (force: \(forceRefresh))")
syncMusicControlWindow(forceRefresh: forceRefresh)
} else {
logMusicControlEvent("Skipping floating window sync (conditions changed)")
hideMusicControlWindow()
}
pendingMusicControlTask = nil
}
}
#endif
}
private func cancelMusicControlWindowSync() {
pendingMusicControlTask?.cancel()
pendingMusicControlTask = nil
}
#if os(macOS)
private func currentMusicControlWindowMetrics() -> MusicControlWindowMetrics {
MusicControlWindowMetrics(
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
rightWingWidth: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
cornerRadius: activeCornerRadiusInsets.closed.bottom,
spacing: 36
)
}
private func syncMusicControlWindow(forceRefresh: Bool = false) {
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
let targetVisible = shouldShowMusicControlWindow() && notchAvailable
if targetVisible {
let metrics = currentMusicControlWindowMetrics()
if !isMusicControlWindowVisible {
let didPresent = MusicControlWindowManager.shared.present(using: vm, metrics: metrics)
isMusicControlWindowVisible = didPresent
} else if forceRefresh {
let didRefresh = MusicControlWindowManager.shared.refresh(using: vm, metrics: metrics)
if !didRefresh {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
} else if isMusicControlWindowVisible {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
private func hideMusicControlWindow() {
if isMusicControlWindowVisible {
MusicControlWindowManager.shared.hide()
isMusicControlWindowVisible = false
}
}
#else
private func syncMusicControlWindow(forceRefresh: Bool = false) {}
private func hideMusicControlWindow() {}
#endif
}
struct FullScreenDropDelegate: DropDelegate {

View file

@ -6816,6 +6816,9 @@
}
}
}
},
"Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : {
},
"Do Not Disturb" : {

View file

@ -711,8 +711,8 @@ struct LockScreenMusicPanel: View {
if #available(macOS 26.0, *) {
RoundedRectangle(cornerRadius: panelCornerRadius)
.glassEffect(
.regular
.tint(Color.white.opacity(0.12))
.clear
//.tint(Color.white.opacity(0.12))
.interactive(),
in: .rect(cornerRadius: panelCornerRadius)
)
@ -739,8 +739,8 @@ struct LockScreenMusicPanel: View {
if #available(macOS 26.0, *) {
RoundedRectangle(cornerRadius: cornerRadius)
.glassEffect(
.regular
.tint(Color.white.opacity(0.16))
.clear
//.tint(Color.white.opacity(0.16))
.interactive(),
in: .rect(cornerRadius: cornerRadius)
)

View file

@ -0,0 +1,276 @@
import SwiftUI
import Defaults
struct MusicControlOverlay: View {
let notchHeight: CGFloat
let cornerRadius: CGFloat
@ObservedObject private var musicManager = MusicManager.shared
@Default(.musicSkipBehavior) private var musicSkipBehavior
private let seekInterval: TimeInterval = 10
private let skipPressMagnitude: CGFloat = 8
private var controlsEnabled: Bool {
!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil
}
private var buttonSide: CGFloat {
let preferred = notchHeight * 0.94
let lowerBound = max(30, notchHeight * 0.8)
let upperBound = max(notchHeight + 2, 32)
return min(max(preferred, lowerBound), upperBound)
}
private var playPauseSide: CGFloat {
min(buttonSide + 8, notchHeight + 4)
}
private var buttonCornerRadius: CGFloat {
max(cornerRadius - 8, buttonSide * 0.28)
}
private var windowCornerRadius: CGFloat {
max(cornerRadius - 6, 12)
}
private var backwardConfig: ButtonConfig {
switch musicSkipBehavior {
case .track:
return ButtonConfig(
icon: "backward.fill",
pressEffect: .nudge(-skipPressMagnitude),
symbolEffect: .replace,
action: { MusicManager.shared.previousTrack() }
)
case .tenSecond:
return ButtonConfig(
icon: "gobackward.10",
pressEffect: .wiggle(.counterClockwise),
symbolEffect: .wiggle,
action: { MusicManager.shared.seek(by: -seekInterval) }
)
}
}
private var forwardConfig: ButtonConfig {
switch musicSkipBehavior {
case .track:
return ButtonConfig(
icon: "forward.fill",
pressEffect: .nudge(skipPressMagnitude),
symbolEffect: .replace,
action: { MusicManager.shared.nextTrack() }
)
case .tenSecond:
return ButtonConfig(
icon: "goforward.10",
pressEffect: .wiggle(.clockwise),
symbolEffect: .wiggle,
action: { MusicManager.shared.seek(by: seekInterval) }
)
}
}
private var playPauseConfig: ButtonConfig {
ButtonConfig(
icon: musicManager.isPlaying ? "pause.fill" : "play.fill",
pressEffect: .none,
symbolEffect: .replace,
action: { MusicManager.shared.togglePlay() }
)
}
var body: some View {
let verticalPadding = max(8, notchHeight * 0.12)
HStack(spacing: 18) {
FloatingMediaButton(
icon: backwardConfig.icon,
frameSize: CGSize(width: buttonSide, height: buttonSide),
cornerRadius: buttonCornerRadius,
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
pressEffect: backwardConfig.pressEffect,
symbolEffectStyle: backwardConfig.symbolEffect,
isEnabled: controlsEnabled,
action: backwardConfig.action
)
FloatingMediaButton(
icon: playPauseConfig.icon,
frameSize: CGSize(width: playPauseSide, height: playPauseSide),
cornerRadius: max(buttonCornerRadius + 2, playPauseSide * 0.32),
foregroundColor: .white,
pressEffect: playPauseConfig.pressEffect,
symbolEffectStyle: playPauseConfig.symbolEffect,
isEnabled: controlsEnabled,
action: playPauseConfig.action
)
FloatingMediaButton(
icon: forwardConfig.icon,
frameSize: CGSize(width: buttonSide, height: buttonSide),
cornerRadius: buttonCornerRadius,
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
pressEffect: forwardConfig.pressEffect,
symbolEffectStyle: forwardConfig.symbolEffect,
isEnabled: controlsEnabled,
action: forwardConfig.action
)
}
.padding(.vertical, verticalPadding)
.padding(.horizontal, 18)
.frame(height: notchHeight)
.frame(minWidth: buttonSide * 3.2)
.background {
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
.fill(Color.black)
}
.compositingGroup()
.animation(.smooth(duration: 0.2), value: musicManager.isPlaying)
.animation(.smooth(duration: 0.2), value: musicSkipBehavior)
.animation(.smooth(duration: 0.2), value: controlsEnabled)
}
private struct ButtonConfig {
let icon: String
let pressEffect: FloatingMediaButton.PressEffect
let symbolEffect: FloatingMediaButton.SymbolEffectStyle
let action: () -> Void
}
}
private struct FloatingMediaButton: View {
let icon: String
let frameSize: CGSize
let cornerRadius: CGFloat
let foregroundColor: Color
let pressEffect: PressEffect
let symbolEffectStyle: SymbolEffectStyle
let isEnabled: Bool
let action: () -> Void
@State private var isHovering = false
@State private var pressOffset: CGFloat = 0
@State private var rotationAngle: Double = 0
@State private var wiggleToken: Int = 0
var body: some View {
Button {
guard isEnabled else { return }
triggerPressEffect()
action()
} label: {
iconView
.frame(width: frameSize.width, height: frameSize.height)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isHovering && isEnabled ? Color.white.opacity(0.18) : .clear)
)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
.buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.rotationEffect(.degrees(rotationAngle))
.disabled(!isEnabled)
.onHover { hovering in
withAnimation(.easeOut(duration: 0.18)) {
isHovering = hovering
}
}
}
private var iconView: some View {
let image = Image(systemName: icon)
.font(.system(size: min(frameSize.width, frameSize.height) * 0.48, weight: .semibold))
.foregroundColor(foregroundColor)
switch symbolEffectStyle {
case .none:
return AnyView(image)
case .replace:
if #available(macOS 14.0, *) {
return AnyView(image.contentTransition(.symbolEffect(.replace)))
} else {
return AnyView(image)
}
case .replaceAndBounce:
if #available(macOS 14.0, *) {
return AnyView(
image
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.bounce, value: icon)
)
} else {
return AnyView(image)
}
case .wiggle:
if #available(macOS 14.0, *) {
return AnyView(
image.symbolEffect(
.wiggle.byLayer,
options: .nonRepeating,
value: wiggleToken
)
)
} else {
return AnyView(image)
}
}
}
private func triggerPressEffect() {
switch pressEffect {
case .none:
return
case .nudge(let amount):
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
pressOffset = 0
}
}
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 11 : -11
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
rotationAngle = 0
}
}
}
}
enum PressEffect {
case none
case nudge(CGFloat)
case wiggle(WiggleDirection)
}
enum SymbolEffectStyle {
case none
case replace
case replaceAndBounce
case wiggle
}
enum WiggleDirection {
case clockwise
case counterClockwise
}
}
#Preview {
MusicControlOverlay(notchHeight: 34, cornerRadius: 14)
.padding()
.background(Color.gray.opacity(0.2))
}

View file

@ -858,6 +858,7 @@ struct Media: View {
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
@Default(.musicAuxRightControl) private var musicAuxRightControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
@ -938,6 +939,12 @@ struct Media: View {
"Enable music live activity",
isOn: $coordinator.musicLiveActivityEnabled.animation()
)
Defaults.Toggle(
"Show floating media controls",
key: .musicControlWindowEnabled
)
.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)
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
Picker("Sneak Peek Style", selection: $sneakPeekStyles){

View file

@ -0,0 +1,218 @@
#if os(macOS)
import AppKit
import SwiftUI
import SkyLightWindow
import QuartzCore
struct MusicControlWindowMetrics: Equatable {
let notchHeight: CGFloat
let notchWidth: CGFloat
let rightWingWidth: CGFloat
let cornerRadius: CGFloat
let spacing: CGFloat
}
@MainActor
final class MusicControlWindowManager {
static let shared = MusicControlWindowManager()
private var window: NSWindow?
private var hostingView: NSHostingView<MusicControlOverlay>?
private var hasDelegated = false
private var lastMetrics: MusicControlWindowMetrics?
private init() {}
@discardableResult
func present(using viewModel: DynamicIslandViewModel, metrics: MusicControlWindowMetrics) -> Bool {
guard let screen = resolveScreen(from: viewModel) else { return false }
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
hide()
return false
}
let overlay = MusicControlOverlay(
notchHeight: metrics.notchHeight,
cornerRadius: metrics.cornerRadius
)
let hosting = ensureHostingView(with: overlay)
let fittingSize = measuredSize(for: hosting)
hosting.frame = NSRect(origin: .zero, size: fittingSize)
let window = ensureWindow(on: screen)
if window.contentView !== hosting {
window.contentView = hosting
}
let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
lastMetrics = metrics
if !hasDelegated {
SkyLightOperator.shared.delegateWindow(window)
hasDelegated = true
}
if window.alphaValue <= 0.01 {
let startFrame = initialFrame(for: targetFrame, metrics: metrics)
window.setFrame(startFrame, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.24
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(targetFrame, display: true)
window.animator().alphaValue = 1
} completionHandler: {
window.setFrame(targetFrame, display: false)
}
} else {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
} completionHandler: {
window.setFrame(targetFrame, display: false)
}
window.orderFrontRegardless()
window.alphaValue = 1
}
return true
}
@discardableResult
func refresh(using viewModel: DynamicIslandViewModel, metrics: MusicControlWindowMetrics) -> Bool {
guard window != nil else {
return present(using: viewModel, metrics: metrics)
}
if let lastMetrics, lastMetrics == metrics {
return true
}
return present(using: viewModel, metrics: metrics)
}
func hide(animated: Bool = true, tearDown: Bool = true) {
guard let window else { return }
guard animated, window.alphaValue > 0.01 else {
window.orderOut(nil)
window.alphaValue = 0
if tearDown {
tearDownHostingView()
}
return
}
let retreatFrame = notchRetreatFrame(from: window.frame)
let originalFrame = window.frame
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
window.animator().setFrame(retreatFrame, display: true)
window.animator().alphaValue = 0
} completionHandler: { [weak self] in
window.setFrame(originalFrame, display: false)
window.orderOut(nil)
window.alphaValue = 0
if tearDown {
self?.tearDownHostingView()
}
}
}
private func tearDownHostingView() {
if let window {
window.contentView = nil
}
hostingView = nil
lastMetrics = nil
}
private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView<MusicControlOverlay> {
if let hostingView {
hostingView.rootView = overlay
return hostingView
}
let view = NSHostingView(rootView: overlay)
hostingView = view
return view
}
private func ensureWindow(on screen: NSScreen) -> NSWindow {
if let window {
return window
}
let window = NSWindow(
contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 240, height: 68),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = false
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window.ignoresMouseEvents = false
window.isMovable = false
window.alphaValue = 0
self.window = window
self.hasDelegated = false
return window
}
private func measuredSize(for hosting: NSHostingView<MusicControlOverlay>) -> CGSize {
hosting.layoutSubtreeIfNeeded()
let size = hosting.fittingSize
return CGSize(width: ceil(size.width), height: ceil(size.height))
}
private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
if let screenName = viewModel.screen,
let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
return targetScreen
}
return NSScreen.main
}
private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: MusicControlWindowMetrics) -> NSRect {
let screenFrame = screen.frame
let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
let originY = screenFrame.maxY - size.height
let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
let rawOriginX = rightEdge + metrics.spacing
let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
}
private func initialFrame(for targetFrame: NSRect, metrics: MusicControlWindowMetrics) -> NSRect {
var frame = targetFrame
let notchRightEdge = targetFrame.minX - metrics.spacing
frame.origin.x = notchRightEdge - targetFrame.width
return frame
}
private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
guard let metrics = lastMetrics else {
return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
}
let notchRightEdge = currentFrame.minX - metrics.spacing
var frame = currentFrame
frame.origin.x = notchRightEdge - currentFrame.width
return frame
}
}
#endif

View file

@ -33,6 +33,7 @@ class MusicManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private var controllerCancellables = Set<AnyCancellable>()
private var debounceIdleTask: Task<Void, Never>?
@MainActor private var pendingOptimisticPlayState: Bool?
// Helper to check if macOS has removed support for NowPlayingController
public private(set) var isNowPlayingDeprecated: Bool = false
@ -193,20 +194,38 @@ class MusicManager: ObservableObject {
forceUpdate()
}
@MainActor
private func applyPlayState(_ state: Bool, animation: Animation?) {
if let animation {
var transaction = Transaction()
transaction.animation = animation
withTransaction(transaction) {
self.isPlaying = state
}
} else {
self.isPlaying = state
}
self.updateIdleState(state: state)
}
// MARK: - Update Methods
@MainActor
private func updateFromPlaybackState(_ state: PlaybackState) {
// Check for playback state changes (playing/paused)
if state.isPlaying != self.isPlaying {
NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")")
withAnimation(.smooth) {
self.isPlaying = state.isPlaying
self.updateIdleState(state: state.isPlaying)
}
let eventIsPlaying = state.isPlaying
let expectedState = pendingOptimisticPlayState
pendingOptimisticPlayState = nil
if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty {
self.updateSneakPeek()
}
if eventIsPlaying != self.isPlaying {
let animation: Animation? = (expectedState == eventIsPlaying) ? .smooth(duration: 0.18) : .smooth
applyPlayState(eventIsPlaying, animation: animation)
} 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
@ -486,8 +505,16 @@ class MusicManager: ObservableObject {
}
func togglePlay() {
guard let controller = activeController else { return }
Task {
await activeController?.togglePlay()
await MainActor.run {
let newState = !isPlaying
pendingOptimisticPlayState = newState
applyPlayState(newState, animation: .smooth(duration: 0.18))
}
await controller.togglePlay()
}
}

View file

@ -447,6 +447,7 @@ extension Defaults.Keys {
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", 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)
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)