From e6fcddae67e87538dbb756f827433d4e89bb848c Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 21 Nov 2025 01:51:01 +0530 Subject: [PATCH] Working on Media controls --- DynamicIsland/ContentView.swift | 245 +++++++++++++++- DynamicIsland/Localizable.xcstrings | 3 + .../LockScreen/LockScreenMusicPanel.swift | 8 +- .../Music/MusicControlOverlay.swift | 273 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 7 + .../managers/MusicControlWindowManager.swift | 199 +++++++++++++ DynamicIsland/managers/MusicManager.swift | 14 +- DynamicIsland/models/Constants.swift | 1 + 8 files changed, 744 insertions(+), 6 deletions(-) create mode 100644 DynamicIsland/components/Music/MusicControlOverlay.swift create mode 100644 DynamicIsland/managers/MusicControlWindowManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 08a65c4..c49aa7a 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -71,13 +71,17 @@ struct ContentView: View { @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero + @State private var isMusicControlWindowVisible = false + @State private var pendingMusicControlSync: DispatchWorkItem? + @State private var musicControlHideWorkItem: DispatchWorkItem? + @State private var musicControlVisibilityDeadline: Date? @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 @@ -91,6 +95,7 @@ struct ContentView: View { private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 private let statsAdditionalRowHeight: CGFloat = 110 + private let musicControlPauseGrace: TimeInterval = 10 // 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 +373,86 @@ struct ContentView: View { .background(dragDetector) .environmentObject(vm) .environmentObject(webcamManager) + .onAppear { + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + if let deadline = musicControlVisibilityDeadline, Date() > deadline { + clearMusicControlVisibilityDeadline() + } + scheduleMusicControlWindowSync(forceRefresh: true) + } + .onChange(of: vm.notchState) { _, state in + if state == .open { + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: musicControlWindowEnabled) { _, enabled in + if enabled { + if musicManager.isPlaying || !musicManager.isPlayerIdle { + clearMusicControlVisibilityDeadline() + } + scheduleMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + } + } + .onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in + if enabled { + scheduleMusicControlWindowSync(forceRefresh: true) + } else { + cancelMusicControlWindowSync() + hideMusicControlWindow() + clearMusicControlVisibilityDeadline() + } + } + .onChange(of: vm.hideOnClosed) { _, hidden in + if hidden { + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: lockScreenManager.isLocked) { _, locked in + if locked { + cancelMusicControlWindowSync() + hideMusicControlWindow() + } else { + scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: gestureProgress) { _, _ in + if shouldShowMusicControlWindow() { + scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05) + } + } + .onChange(of: isHovering) { _, hovering in + if shouldShowMusicControlWindow() { + scheduleMusicControlWindowSync(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() { + scheduleMusicControlWindowSync(forceRefresh: true) + } + } + .onChange(of: vm.effectiveClosedNotchHeight) { _, _ in + if shouldShowMusicControlWindow() { + scheduleMusicControlWindowSync(forceRefresh: true) + } + } .onDisappear { // Clean up all timer work items to prevent memory leaks and conflicts hoverWorkItem?.cancel() @@ -375,6 +460,10 @@ struct ContentView: View { statsTransitionWorkItem?.cancel() viewTransitionWorkItem?.cancel() sizeChangeWorkItem?.cancel() + cancelMusicControlWindowSync() + hideMusicControlWindow() + cancelMusicControlVisibilityTimer() + clearMusicControlVisibilityDeadline() } } @@ -910,6 +999,160 @@ struct ContentView: View { } } } + + private func handleMusicControlPlaybackChange(isPlaying: Bool) { + guard musicControlWindowEnabled else { return } + + if isPlaying { + clearMusicControlVisibilityDeadline() + scheduleMusicControlWindowSync(forceRefresh: true) + } 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) + scheduleMusicControlWindowSync(forceRefresh: true) + } + + private func clearMusicControlVisibilityDeadline() { + musicControlVisibilityDeadline = nil + cancelMusicControlVisibilityTimer() + } + + private func scheduleMusicControlVisibilityCheck(deadline: Date) { + cancelMusicControlVisibilityTimer() + + let workItem = DispatchWorkItem { + musicControlHideWorkItem = nil + + if let currentDeadline = musicControlVisibilityDeadline, currentDeadline <= Date() { + musicControlVisibilityDeadline = nil + } + + scheduleMusicControlWindowSync(forceRefresh: true) + } + + musicControlHideWorkItem = workItem + let interval = max(0, deadline.timeIntervalSinceNow) + DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: workItem) + } + + private func cancelMusicControlVisibilityTimer() { + musicControlHideWorkItem?.cancel() + musicControlHideWorkItem = nil + } + + private func musicControlVisibilityIsActive() -> Bool { + if musicManager.isPlaying { + return true + } + + guard let deadline = musicControlVisibilityDeadline else { return false } + return Date() <= deadline + } + + private func shouldShowMusicControlWindow() -> Bool { + guard musicControlWindowEnabled, + coordinator.musicLiveActivityEnabled, + vm.notchState == .closed, + !vm.hideOnClosed, + !lockScreenManager.isLocked else { + return false + } + + return musicControlVisibilityIsActive() + } + + private func scheduleMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { + #if os(macOS) + cancelMusicControlWindowSync() + + guard shouldShowMusicControlWindow() else { + hideMusicControlWindow() + return + } + + let syncDelay = max(0, delay) + let workItem = DispatchWorkItem { [forceRefresh] in + pendingMusicControlSync = nil + + if shouldShowMusicControlWindow() { + syncMusicControlWindow(forceRefresh: forceRefresh) + } else { + hideMusicControlWindow() + } + } + + pendingMusicControlSync = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + syncDelay, execute: workItem) + #endif + } + + private func cancelMusicControlWindowSync() { + pendingMusicControlSync?.cancel() + pendingMusicControlSync = 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 { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b757544..ecd459f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -6816,6 +6816,9 @@ } } } + }, + "Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : { + }, "Do Not Disturb" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index adf3d70..6299566 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -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) ) diff --git a/DynamicIsland/components/Music/MusicControlOverlay.swift b/DynamicIsland/components/Music/MusicControlOverlay.swift new file mode 100644 index 0000000..1dd431c --- /dev/null +++ b/DynamicIsland/components/Music/MusicControlOverlay.swift @@ -0,0 +1,273 @@ +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 { + 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(.horizontal, 18) + .frame(height: notchHeight) + .frame(minWidth: buttonSide * 3.2) + .background { + RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous) + .fill(Color.black.opacity(0.88)) + } + .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.42, 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)) +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index aef213f..4f375fa 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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){ diff --git a/DynamicIsland/managers/MusicControlWindowManager.swift b/DynamicIsland/managers/MusicControlWindowManager.swift new file mode 100644 index 0000000..115cc14 --- /dev/null +++ b/DynamicIsland/managers/MusicControlWindowManager.swift @@ -0,0 +1,199 @@ +#if os(macOS) +import AppKit +import SwiftUI +import SkyLightWindow +import QuartzCore + +struct MusicControlWindowMetrics { + 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? + 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) + } + return present(using: viewModel, metrics: metrics) + } + + func hide(animated: Bool = true) { + guard let window else { return } + + guard animated, window.alphaValue > 0.01 else { + window.orderOut(nil) + window.alphaValue = 0 + 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: { + window.setFrame(originalFrame, display: false) + window.orderOut(nil) + window.alphaValue = 0 + } + } + + private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView { + 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) -> 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 diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 4aad931..b5dbb3a 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -486,8 +486,20 @@ class MusicManager: ObservableObject { } func togglePlay() { + let controller = activeController + Task { - await activeController?.togglePlay() + await MainActor.run { + let newState = !isPlaying + withAnimation(.smooth(duration: 0.18)) { + isPlaying = newState + if newState { + isPlayerIdle = false + } + } + } + + await controller?.togglePlay() } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4f7607b..5ca47b7 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -447,6 +447,7 @@ extension Defaults.Keys { static let musicAuxRightControl = Key("musicAuxRightControl", default: .repeatMode) static let didMigrateMusicAuxControls = Key("didMigrateMusicAuxControls", default: false) static let musicSkipBehavior = Key("musicSkipBehavior", default: .track) + static let musicControlWindowEnabled = Key("musicControlWindowEnabled", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true)