diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index cce2b6e..1e64f49 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -33289,6 +33289,9 @@ } } } + }, + "Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect." : { + }, "Request Access" : { "localizations" : { @@ -34267,7 +34270,7 @@ } } }, - "Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings." : { + "Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas." : { }, "Ring" : { @@ -37328,6 +37331,9 @@ } } } + }, + "Show live canvas in Dynamic Island" : { + }, "Show location label" : { "localizations" : { @@ -43392,6 +43398,9 @@ } } } + }, + "Use album art layout over fullscreen canvas" : { + }, "Use circular battery indicator" : { "localizations" : { diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index e1e56db..5950ada 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -166,9 +166,12 @@ class SpotifyController: MediaControllerProtocol { state.artwork = existingArtwork } - if trackURI == currentCanvasTrackURI { + let isSameCanvasTrack = trackURI == currentCanvasTrackURI + + if isSameCanvasTrack { state.liveArtworkURL = playbackState.liveArtworkURL } else { + state.liveArtworkURL = nil currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI canvasFetchTask?.cancel() canvasFetchTask = nil @@ -185,6 +188,7 @@ class SpotifyController: MediaControllerProtocol { artworkFetchTask?.cancel() let currentState = state + let expectedTrackURI = trackURI artworkFetchTask = Task { do { @@ -192,6 +196,10 @@ class SpotifyController: MediaControllerProtocol { await MainActor.run { [weak self] in guard let self = self else { return } + guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else { + self.artworkFetchTask = nil + return + } var updatedState = currentState updatedState.artwork = data updatedState.liveArtworkURL = self.playbackState.liveArtworkURL diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 3edc5f1..f5830fa 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -36,6 +36,7 @@ struct LockScreenMusicPanel: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared + @ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @ObservedObject private var animator: LockScreenPanelAnimator @@ -122,6 +123,22 @@ struct LockScreenMusicPanel: View { private var usesEnhancedCustomLiquidBorder: Bool { usesCustomLiquidGlass && useEnhancedLiquidBorder } + + private var hidesInlineArtworkForSpotifyCanvasFallback: Bool { + fullscreenArtworkManager.isShowingSpotifyCanvasFallback + } + + private var usesSpotifyCanvasFallbackContentPresentation: Bool { + fullscreenArtworkManager.isShowingSpotifyCanvasFallback + } + + private var showsDetachedFullscreenLyrics: Bool { + usesSpotifyCanvasFallbackContentPresentation && enableLyrics + } + + private var shouldShowInlineLyrics: Bool { + enableLyrics && !showsDetachedFullscreenLyrics + } var body: some View { if isActive && musicManager.hasActiveSession { @@ -140,12 +157,17 @@ struct LockScreenMusicPanel: View { .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { - if showPanelBorder { + if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation { panelBorderOverlay } } - .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) - .contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) + .shadow( + color: usesSpotifyCanvasFallbackContentPresentation ? .clear : Color.black.opacity(0.3), + radius: usesSpotifyCanvasFallbackContentPresentation ? 0 : 20, + x: 0, + y: usesSpotifyCanvasFallbackContentPresentation ? 0 : 10 + ) + .contentShape(Rectangle()) .animation(.easeInOut(duration: 0.28), value: isExpanded) .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .onAppear { @@ -231,9 +253,13 @@ struct LockScreenMusicPanel: View { collapsedLayout } } - .padding(.horizontal, isExpanded ? 24 : 20) - .padding(.vertical, isExpanded ? 22 : 16) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? 8 : (isExpanded ? 24 : 20)) + .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? 0 : (isExpanded ? 22 : 16)) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading + ) } private var collapsedLayout: some View { @@ -248,25 +274,40 @@ struct LockScreenMusicPanel: View { } private var expandedLayout: some View { - HStack(alignment: .center, spacing: expandedContentSpacing) { - albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) - .frame(width: 230, height: 230) + Group { + if hidesInlineArtworkForSpotifyCanvasFallback { + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack(alignment: .center, spacing: expandedContentSpacing) { + albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) + .frame(width: 230, height: 230) - VStack(alignment: .leading, spacing: 20) { - expandedHeader - progressBar - .padding(.top, 10) - .frame(maxWidth: .infinity) - playbackControls(alignment: .leading) + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } } - .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: .infinity, alignment: .center) } private var collapsedHeader: some View { - HStack(alignment: .center, spacing: 16) { - albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) + HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) { + if !hidesInlineArtworkForSpotifyCanvasFallback { + albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) + } GeometryReader { geo in VStack(alignment: .leading, spacing: 1) { @@ -338,9 +379,11 @@ struct LockScreenMusicPanel: View { } private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { - ZStack(alignment: .bottomTrailing) { + let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius) + + return ZStack(alignment: .bottomTrailing) { if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous) .fill(Color.white.opacity(0.06)) .overlay( Image(systemName: "photo.fill") @@ -366,8 +409,8 @@ struct LockScreenMusicPanel: View { suspended: isParallaxSuspended ) .frame(width: size) - .background(albumArtBackground(cornerRadius: cornerRadius)) - .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous)) + .background(albumArtBackground(cornerRadius: artworkCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)) .opacity(musicManager.isPlaying ? 1 : 0.4) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) @@ -544,7 +587,7 @@ struct LockScreenMusicPanel: View { private func playbackControls(alignment: Alignment) -> some View { let spacing: CGFloat = isExpanded ? 24 : 20 - let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10 + let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10 return VStack(spacing: verticalSpacing) { controlsRow(alignment: alignment, spacing: spacing) @@ -565,7 +608,7 @@ struct LockScreenMusicPanel: View { } } - if enableLyrics { + if shouldShowInlineLyrics { lyricsSection(alignment: alignment) .transition(.move(edge: .top).combined(with: .opacity)) } @@ -1009,7 +1052,7 @@ struct LockScreenMusicPanel: View { } private var lyricsExtraHeight: CGFloat { - lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) + lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics) } private var accessorySectionExtraHeight: CGFloat { @@ -1039,7 +1082,7 @@ struct LockScreenMusicPanel: View { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + accessorySectionExtraHeight + - lyricsHeight(forExpanded: expanded, enabled: enableLyrics) + lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics) } private func updatePanelSize(animated: Bool = true) { @@ -1094,7 +1137,11 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackgroundLayer: some View { - if usesCustomLiquidGlass { + if usesSpotifyCanvasFallbackContentPresentation { + Color.clear + .allowsHitTesting(false) + .accessibilityHidden(true) + } else if usesCustomLiquidGlass { customLiquidPanelBackdrop } else if usesStandardLiquidGlass { standardLiquidPanelBackdrop @@ -1192,7 +1239,8 @@ struct LockScreenMusicPanel: View { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? cornerRadius/3 : cornerRadius)) + .compositingGroup() + .clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous)) } @ViewBuilder @@ -1230,6 +1278,13 @@ struct LockScreenMusicPanel: View { isExpanded ? 18 : 12 } + private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat { + let aspectRatio = musicManager.albumArt.size.height > 0 + ? musicManager.albumArt.size.width / musicManager.albumArt.size.height + : 1 + return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius + } + @available(macOS 26.0, *) private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) @@ -1480,9 +1535,48 @@ private extension View { final class RightClickNSView: NSView { var onRightClick: (() -> Void)? + private var eventMonitor: Any? - override func rightMouseDown(with event: NSEvent) { - onRightClick?() + deinit { + removeEventMonitor() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if window == nil { + removeEventMonitor() + } else { + installEventMonitorIfNeeded() + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in + guard let self, let window = self.window, event.window === window else { + return event + } + + let pointInView = self.convert(event.locationInWindow, from: nil) + guard self.bounds.contains(pointInView) else { + return event + } + + self.onRightClick?() + return nil + } + } + + private func removeEventMonitor() { + guard let eventMonitor else { return } + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil } } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 5b020a9..6e5b273 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -1113,8 +1113,17 @@ private struct MinimalisticReminderDetailsView: View { struct MinimalisticAlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland let albumArtNamespace: Namespace.ID + private var usesLiveCanvasArtwork: Bool { + showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil + } + + private var albumArtCornerRadius: CGFloat { + musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 ? 4 : 12 + } + var body: some View { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { @@ -1128,16 +1137,27 @@ struct MinimalisticAlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .scaledToFill() + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) ) .clipped() - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius)) .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) .blur(radius: 35) - .opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) + .opacity( + usesLiveCanvasArtwork + ? (musicManager.isPlaying ? 0.68 : 0.22) + : min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3)) + ) + .shadow( + color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.28 : 0.16), + radius: usesLiveCanvasArtwork ? 18 : 12, + x: 0, + y: 0 + ) } private var albumArtButton: some View { @@ -1147,13 +1167,10 @@ struct MinimalisticAlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12)) - - + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) ) .clipped() .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 9d10cd5..cea0a9c 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -23,6 +23,112 @@ import Combine import Defaults import SwiftUI +import AppKit +import AVFoundation + +private final class DynamicIslandArtworkLoopController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + + init(url: URL) { + let item = AVPlayerItem(url: url) + player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + looper = AVPlayerLooper(player: player, templateItem: item) + player.play() + } + + deinit { + player.pause() + looper = nil + } +} + +private final class DynamicIslandArtworkVideoContainerView: NSView { + let playerLayer = AVPlayerLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + playerLayer.videoGravity = .resizeAspectFill + playerLayer.backgroundColor = NSColor.clear.cgColor + layer?.addSublayer(playerLayer) + } + + required init?(coder: NSCoder) { + nil + } + + override func layout() { + super.layout() + playerLayer.frame = bounds + } +} + +private struct DynamicIslandArtworkVideoView: NSViewRepresentable { + let url: URL + let videoGravity: AVLayerVideoGravity + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> DynamicIslandArtworkVideoContainerView { + let view = DynamicIslandArtworkVideoContainerView(frame: .zero) + context.coordinator.attach(layer: view.playerLayer, url: url, gravity: videoGravity) + return view + } + + func updateNSView(_ nsView: DynamicIslandArtworkVideoContainerView, context: Context) { + context.coordinator.attach(layer: nsView.playerLayer, url: url, gravity: videoGravity) + } + + final class Coordinator { + private var controller: DynamicIslandArtworkLoopController? + private var currentURL: URL? + + func attach(layer: AVPlayerLayer, url: URL, gravity: AVLayerVideoGravity) { + layer.videoGravity = gravity + + if currentURL != url || controller == nil { + currentURL = url + controller = DynamicIslandArtworkLoopController(url: url) + } + + if layer.player !== controller?.player { + layer.player = controller?.player + } + } + } +} + +struct DynamicIslandArtworkSourceView: View { + @ObservedObject private var musicManager = MusicManager.shared + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland + + let cornerRadius: CGFloat + let contentMode: ContentMode + + private var liveCanvasURL: URL? { + guard showLiveCanvasInDynamicIsland else { return nil } + return musicManager.videoArtworkURL + } + + var body: some View { + Group { + if let liveCanvasURL { + DynamicIslandArtworkVideoView(url: liveCanvasURL, videoGravity: .resizeAspectFill) + } else { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: contentMode) + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } +} // MARK: - Music Player Components @@ -43,8 +149,23 @@ struct MusicPlayerView: View { struct AlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland let albumArtNamespace: Namespace.ID + private var usesLiveCanvasArtwork: Bool { + showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil + } + + private var albumArtCornerRadius: CGFloat { + Defaults[.cornerRadiusScaling] + ? musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 + ? MusicPlayerImageSizes.cornerRadiusInset.opened / 3 + : MusicPlayerImageSizes.cornerRadiusInset.opened + : musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 + ? MusicPlayerImageSizes.cornerRadiusInset.closed / 3 + : MusicPlayerImageSizes.cornerRadiusInset.closed + } + var body: some View { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { @@ -58,24 +179,26 @@ struct AlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape( - RoundedRectangle( - cornerRadius: Defaults[.cornerRadiusScaling] - ? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened - : musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed - - - ) - ) + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) ) .clipped() .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) .blur(radius: 40) - .opacity(musicManager.isPlaying ? 0.5 : 0) + .opacity( + usesLiveCanvasArtwork + ? (musicManager.isPlaying ? 0.62 : 0.18) + : (musicManager.isPlaying ? 0.5 : 0) + ) + .shadow( + color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.24 : 0.16), + radius: usesLiveCanvasArtwork ? 22 : 14, + x: 0, + y: 0 + ) } private var albumArtButton: some View { @@ -111,18 +234,10 @@ struct AlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .overlay { - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape( - RoundedRectangle( - cornerRadius: Defaults[.cornerRadiusScaling] - ? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened - : musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed - - - ) - ) // ensures that even the rectagular album cover is rounded just like the MacOS media player + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) } .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .clipped() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index e3cc1ee..605e671 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -763,6 +763,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")), SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")), SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")), + SettingsSearchEntry(tab: .media, title: "Show live canvas in Dynamic Island", keywords: ["canvas", "live canvas", "album art", "dynamic island", "spotify canvas"], highlightID: SettingsTab.media.highlightID(for: "Show live canvas in Dynamic Island")), SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")), SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")), SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")), @@ -2609,6 +2610,11 @@ struct Media: View { Text("Enable lyrics") } .settingsHighlight(id: highlightID("Enable lyrics")) + Defaults.Toggle(key: .showLiveCanvasInDynamicIsland) { + Text("Show live canvas in Dynamic Island") + } + .settingsHighlight(id: highlightID("Show live canvas in Dynamic Island")) + .help("Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect.") //Parallax Effect Intensity to control how much parallax is wanted Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { @@ -2703,12 +2709,17 @@ struct Media: View { } .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) { + Text("Use album art layout over fullscreen canvas") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas")) Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { Text("Keep album art visible during fullscreen artwork") } .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) - Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings.") + Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -4833,12 +4844,17 @@ struct LockScreenSettings: View { } .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) { + Text("Use album art layout over fullscreen canvas") + } + .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) + .settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas")) Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { Text("Keep album art visible during fullscreen artwork") } .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) - Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings.") + Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift index c94a924..b58b3c8 100644 --- a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -19,6 +19,8 @@ import AppKit import AVFoundation import Combine +import CoreImage +import Defaults import SkyLightWindow import SwiftUI @@ -28,7 +30,7 @@ private class ClickReceiverWindow: NSWindow { var onClick: (() -> Void)? override func mouseDown(with event: NSEvent) { - onClick?() + // Keep left click inert so the fallback only dismisses on right click. } override func rightMouseDown(with event: NSEvent) { @@ -89,13 +91,173 @@ private final class LoopingVideoView: NSView { } } +private final class WallpaperTransitionImageView: NSView { + private let imageLayer = CALayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + override func layout() { + super.layout() + imageLayer.frame = bounds + } + + func updateImage(_ image: NSImage) { + imageLayer.contents = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + } +} + +private final class SpotifyCanvasFallbackArtworkOverlayView: NSView { + var onDismiss: (() -> Void)? + + private let imageLayer = CALayer() + private let borderLayer = CAShapeLayer() + private var artwork: NSImage + + init(artwork: NSImage) { + self.artwork = artwork + super.init(frame: .zero) + + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.shadowColor = NSColor.black.withAlphaComponent(0.34).cgColor + layer?.shadowOpacity = 1 + layer?.shadowRadius = 26 + layer?.shadowOffset = CGSize(width: 0, height: -22) + + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + + borderLayer.fillColor = NSColor.clear.cgColor + borderLayer.strokeColor = NSColor.white.withAlphaComponent(0.18).cgColor + borderLayer.lineWidth = 1.1 + + layer?.addSublayer(imageLayer) + layer?.addSublayer(borderLayer) + } + + required init?(coder: NSCoder) { + nil + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func layout() { + super.layout() + + let radius = cornerRadius + imageLayer.frame = bounds + imageLayer.cornerRadius = radius + borderLayer.frame = bounds + borderLayer.path = CGPath( + roundedRect: bounds.insetBy(dx: 0.55, dy: 0.55), + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + + layer?.shadowPath = CGPath( + roundedRect: bounds, + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount >= 2 { + onDismiss?() + } + } + + override func rightMouseDown(with event: NSEvent) { + onDismiss?() + } + + func updateArtwork(_ artwork: NSImage) { + self.artwork = artwork + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + needsLayout = true + } + + private var aspectRatio: CGFloat { + let height = max(artwork.size.height, 1) + return artwork.size.width / height + } + + private var cornerRadius: CGFloat { + aspectRatio > 1 ? 16 : 34 + } +} + +private struct FullScreenLyricsOverlayContent: View { + let text: String + let fontSize: CGFloat + + private var isPlaceholder: Bool { + text == "Loading lyrics..." || text == "No lyrics found" + } + + var body: some View { + VStack { + Text(text) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundColor(.white.opacity(isPlaceholder ? 0.72 : 0.94)) + .multilineTextAlignment(.center) + .lineLimit(4) + .minimumScaleFactor(0.62) + .allowsTightening(true) + .shadow(color: .black.opacity(0.42), radius: 18, x: 0, y: 6) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(Color.clear) + } +} + +private struct SpotifyCanvasFallbackLayoutFrames { + let artworkFrame: NSRect + let panelFrame: NSRect + let groupFrame: NSRect + let lyricsFrame: NSRect? +} + // MARK: - Window Manager @MainActor -final class FullScreenArtworkWindowManager { +final class FullScreenArtworkWindowManager: ObservableObject { + private enum ArtworkOverlayMode { + case none + case spotifyFallback + } + static let shared = FullScreenArtworkWindowManager() - private(set) var isShowing = false + @Published private(set) var isShowing = false + @Published private(set) var isShowingSpotifyCanvasFallback = false var onDismiss: (() -> Void)? private let wallpaperPlistURL: URL = { @@ -133,7 +295,10 @@ final class FullScreenArtworkWindowManager { }() private var artworkFileURL: URL? private var cachedArtworkPNG: URL? - private var cachedArtworkIdentifier: String? + private var cachedArtworkFingerprint: String? + private var cachedBlurredArtworkPNG: URL? + private var cachedBlurredArtworkFingerprint: String? + private var cachedBlurredArtworkPixelSize: CGSize = .zero private var activeSongTitle: String? private var activeArtist: String? private var trackChangeCancellable: AnyCancellable? @@ -143,61 +308,48 @@ final class FullScreenArtworkWindowManager { private var clickWindowDelegated = false private var videoWindow: NSWindow? private var videoView: LoopingVideoView? + private var activeVideoWindowURL: URL? + private var videoWindowHideTask: Task? + private var artworkOverlayWindow: NSWindow? + private var artworkOverlayWindowDelegated = false + private var lyricsOverlayWindow: NSWindow? + private var lyricsOverlayWindowDelegated = false + private var currentArtworkOverlayMode: ArtworkOverlayMode = .none + private var wallpaperTransitionWindow: NSWindow? + private var wallpaperTransitionView: WallpaperTransitionImageView? + private var wallpaperTransitionHideTask: Task? + private var panelFrameChangeObserver: NSObjectProtocol? private var liveWallpaperTask: Task? + private var deferredTrackRefreshTask: Task? private var isLiveWallpaperAllowed = false private var activeLiveWallpaperFingerprint: String? + private var activeWallpaperKey: String? + private var fallbackRightClickMonitor: Any? + private var artworkLayoutOverCanvasPreferenceCancellable: AnyCancellable? + private var lyricsTextCancellable: AnyCancellable? + private var lyricsPreferenceCancellable: AnyCancellable? + private let spotifyCanvasFallbackHorizontalMargin: CGFloat = 48 private init() { observeArtworkChanges() observeVideoArtworkChanges() + observePanelFrameChanges() + observeArtworkLayoutOverCanvasPreference() + observeLyricsChanges() + observeLyricsPreference() } func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { guard !isShowing else { return } guard let screen = NSScreen.main else { return } - - let fileURL: URL - let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" - - if let cached = cachedArtworkPNG, - cachedArtworkIdentifier == artworkID, - FileManager.default.fileExists(atPath: cached.path) { - fileURL = cached - } else { - guard let encoded = encodeToPNG(artwork) else { return } - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png") - do { - try encoded.write(to: tempURL, options: .atomic) - } catch { - return - } - fileURL = tempURL - cachedArtworkPNG = tempURL - cachedArtworkIdentifier = artworkID - } - - artworkFileURL = fileURL - isLiveWallpaperAllowed = allowLiveWallpaper - activeLiveWallpaperFingerprint = nil - backupWallpaperConfig() - - guard applyArtworkToPlist(imageURL: fileURL) else { - print("[FullScreenArtworkWindowManager] Failed to patch plist") - return - } - - restartWallpaperAgent() - hideVideoWindow() - showClickReceiver(on: screen) - - isShowing = true - activeSongTitle = MusicManager.shared.songTitle - activeArtist = MusicManager.shared.artistName - + applyPresentation( + artwork: artwork, + videoURL: videoURL, + allowLiveWallpaper: allowLiveWallpaper, + on: screen, + backupWallpaperConfiguration: true + ) observeTrackChanges() - scheduleLiveWallpaperPreparation(for: videoURL, artwork: artwork, identifier: artworkID) - - print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") } func hide() { @@ -205,13 +357,25 @@ final class FullScreenArtworkWindowManager { isShowing = false isLiveWallpaperAllowed = false activeLiveWallpaperFingerprint = nil + activeWallpaperKey = nil + let shouldRestoreStandardPanelLayout = isShowingSpotifyCanvasFallback + isShowingSpotifyCanvasFallback = false + removeFallbackRightClickMonitor() trackChangeCancellable?.cancel() trackChangeCancellable = nil liveWallpaperTask?.cancel() liveWallpaperTask = nil + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = nil hideVideoWindow() + hideArtworkOverlay() + hideLyricsOverlay() + hideWallpaperTransition() hideClickReceiver() + if shouldRestoreStandardPanelLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } restoreWallpaper() activeSongTitle = nil @@ -228,18 +392,21 @@ final class FullScreenArtworkWindowManager { private func observeArtworkChanges() { artworkCacheCancellable = MusicManager.shared.$albumArt - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .debounce(for: .milliseconds(180), scheduler: DispatchQueue.main) .sink { [weak self] newArt in guard let self else { return } - let artID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" - guard artID != self.cachedArtworkIdentifier else { return } + guard let fingerprint = self.imageFingerprint(for: newArt) else { return } + guard fingerprint != self.cachedArtworkFingerprint else { return } + let url = self.artworkCacheFileURL(for: fingerprint) Task.detached(priority: .utility) { guard let pngData = self.encodeToPNG(newArt) else { return } - let url = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png") try? pngData.write(to: url, options: .atomic) await MainActor.run { self.cachedArtworkPNG = url - self.cachedArtworkIdentifier = artID + self.cachedArtworkFingerprint = fingerprint + if self.isShowing { + self.refreshPresentationForCurrentTrack() + } } } } @@ -247,18 +414,286 @@ final class FullScreenArtworkWindowManager { private func observeVideoArtworkChanges() { videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .debounce(for: .milliseconds(120), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) - .sink { [weak self] videoURL in + .sink { [weak self] _ in guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } - let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" - self.scheduleLiveWallpaperPreparation( - for: videoURL, - artwork: MusicManager.shared.albumArt, - identifier: artworkID - ) + self.refreshPresentationForCurrentTrack() } } + private func applyPresentation( + artwork: NSImage, + videoURL: URL?, + allowLiveWallpaper: Bool, + on screen: NSScreen, + backupWallpaperConfiguration: Bool + ) { + let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard let artworkFingerprint = imageFingerprint(for: artwork) else { return } + isLiveWallpaperAllowed = allowLiveWallpaper + let shouldUseStaticFallbackWallpaper = shouldUseSpotifyStaticFallbackWallpaper(videoURL: videoURL) + let shouldUseFallbackLayout = shouldUseSpotifyFallbackLayout(videoURL: videoURL) + let effectiveVideoURL = resolvedVideoURL(videoURL: videoURL) + let overlayMode = artworkOverlayMode(shouldUseFallbackLayout: shouldUseFallbackLayout) + let previousFallbackState = isShowingSpotifyCanvasFallback + + guard let rawArtworkFileURL = persistedArtworkFileURL(for: artwork, fingerprint: artworkFingerprint) else { return } + let wallpaperURL = shouldUseStaticFallbackWallpaper + ? blurredWallpaperFileURL(for: artwork, fingerprint: artworkFingerprint, screen: screen) ?? rawArtworkFileURL + : rawArtworkFileURL + let wallpaperKey = wallpaperIdentityKey( + fingerprint: artworkFingerprint, + usesFallback: shouldUseStaticFallbackWallpaper, + screen: screen + ) + let desiredLiveWallpaperFingerprint = effectiveVideoURL?.absoluteString + let requiresWallpaperRefresh = backupWallpaperConfiguration + || activeWallpaperKey != wallpaperKey + || activeLiveWallpaperFingerprint != desiredLiveWallpaperFingerprint + + artworkFileURL = wallpaperURL + + if backupWallpaperConfiguration { + backupWallpaperConfig() + } + + if requiresWallpaperRefresh { + showWallpaperTransition(on: screen, imageURL: wallpaperURL) + + guard applyArtworkToPlist(imageURL: wallpaperURL) else { + print("[FullScreenArtworkWindowManager] Failed to patch plist") + hideWallpaperTransition() + return + } + + restartWallpaperAgent() + activeWallpaperKey = wallpaperKey + activeLiveWallpaperFingerprint = nil + } + + isShowing = true + isShowingSpotifyCanvasFallback = shouldUseFallbackLayout + activeSongTitle = MusicManager.shared.songTitle + activeArtist = MusicManager.shared.artistName + + if overlayMode == .spotifyFallback { + installFallbackRightClickMonitorIfNeeded() + } else { + removeFallbackRightClickMonitor() + } + + if overlayMode == .none { + hideArtworkOverlay() + } else { + showArtworkOverlay(on: screen, artwork: artwork, mode: overlayMode) + } + + updateLyricsOverlayIfNeeded(on: screen) + + if previousFallbackState != shouldUseFallbackLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } + + showClickReceiver(on: screen) + scheduleLiveWallpaperPreparation(for: effectiveVideoURL, artwork: artwork, identifier: artworkID) + + if effectiveVideoURL == nil { + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + + print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") + } + + private func refreshPresentationForCurrentTrack() { + guard isShowing else { return } + guard MusicManager.shared.hasActiveSession else { return } + guard let screen = NSScreen.main else { return } + + applyPresentation( + artwork: MusicManager.shared.albumArt, + videoURL: isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil, + allowLiveWallpaper: isLiveWallpaperAllowed, + on: screen, + backupWallpaperConfiguration: false + ) + } + + private func observePanelFrameChanges() { + panelFrameChangeObserver = NotificationCenter.default.addObserver( + forName: .atollLockScreenPanelFrameDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateArtworkOverlayFrameIfNeeded() + self?.updateLyricsOverlayIfNeeded() + } + } + } + + private func observeArtworkLayoutOverCanvasPreference() { + artworkLayoutOverCanvasPreferenceCancellable = Defaults.publisher(.lockScreenUseArtworkLayoutOverFullscreenCanvas) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.refreshPresentationForCurrentTrack() + } + } + + private func observeLyricsChanges() { + lyricsTextCancellable = MusicManager.shared.$currentLyrics + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func observeLyricsPreference() { + lyricsPreferenceCancellable = Defaults.publisher(.enableLyrics) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func shouldUseSpotifyStaticFallbackWallpaper(videoURL: URL?) -> Bool { + guard MusicManager.shared.bundleIdentifier == SpotifyController.bundleIdentifier else { + return false + } + + return videoURL == nil + } + + private func shouldUseSpotifyFallbackLayout(videoURL: URL?) -> Bool { + guard MusicManager.shared.bundleIdentifier == SpotifyController.bundleIdentifier else { + return false + } + + if videoURL == nil { + return true + } + + return Defaults[.lockScreenUseArtworkLayoutOverFullscreenCanvas] + } + + private func resolvedVideoURL(videoURL: URL?) -> URL? { + guard isLiveWallpaperAllowed else { return nil } + return videoURL + } + + private func artworkOverlayMode(shouldUseFallbackLayout: Bool) -> ArtworkOverlayMode { + if shouldUseFallbackLayout { + return .spotifyFallback + } + + return .none + } + + func spotifyCanvasFallbackInterItemSpacing(screenFrame: NSRect) -> CGFloat { + min(max(screenFrame.width * 0.018, 28), 42) + } + + func spotifyCanvasFallbackArtworkSideLength(screenFrame: NSRect, panelSize: CGSize) -> CGFloat { + let preferred = min(max(screenFrame.height * 0.28, 250), 330) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let maxByWidth = max( + min(screenFrame.width - (spotifyCanvasFallbackHorizontalMargin * 2) - spacing - panelSize.width, 360), + 180 + ) + let maxByHeight = max(min(screenFrame.height * 0.42, 360), 180) + return min(preferred, maxByWidth, maxByHeight) + } + + private func persistedArtworkFileURL(for artwork: NSImage, fingerprint: String) -> URL? { + if let cached = cachedArtworkPNG, + cachedArtworkFingerprint == fingerprint, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeToPNG(artwork) else { return nil } + + let tempURL = artworkCacheFileURL(for: fingerprint) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedArtworkPNG = tempURL + cachedArtworkFingerprint = fingerprint + return tempURL + } catch { + return nil + } + } + + private func blurredWallpaperFileURL(for artwork: NSImage, fingerprint: String, screen: NSScreen) -> URL? { + let pixelSize = wallpaperPixelSize(for: screen) + + if let cached = cachedBlurredArtworkPNG, + cachedBlurredArtworkFingerprint == fingerprint, + cachedBlurredArtworkPixelSize == pixelSize, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeBlurredWallpaperPNG(from: artwork, targetPixelSize: pixelSize) else { + return nil + } + + let tempURL = blurredArtworkCacheFileURL(for: fingerprint, pixelSize: pixelSize) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedBlurredArtworkPNG = tempURL + cachedBlurredArtworkFingerprint = fingerprint + cachedBlurredArtworkPixelSize = pixelSize + return tempURL + } catch { + return nil + } + } + + private func wallpaperPixelSize(for screen: NSScreen) -> CGSize { + let scale = max(screen.backingScaleFactor, 1) + let frame = screen.frame + return CGSize( + width: max(frame.width * scale, 1), + height: max(frame.height * scale, 1) + ) + } + + private func artworkCacheFileURL(for fingerprint: String) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_\(normalizedFingerprint).png") + } + + private func blurredArtworkCacheFileURL(for fingerprint: String, pixelSize: CGSize) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_blurred_\(normalizedFingerprint)_\(width)x\(height).png") + } + + private func normalizedFingerprintComponent(_ fingerprint: String) -> String { + fingerprint.replacingOccurrences(of: "-", with: "_") + } + + private func wallpaperIdentityKey(fingerprint: String, usesFallback: Bool, screen: NSScreen) -> String { + if usesFallback { + let pixelSize = wallpaperPixelSize(for: screen) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return "fallback:\(fingerprint):\(width)x\(height)" + } + + return "artwork:\(fingerprint)" + } + private nonisolated func encodeToPNG(_ image: NSImage) -> Data? { guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) @@ -266,26 +701,103 @@ final class FullScreenArtworkWindowManager { return bitmap.representation(using: .png, properties: [:]) } + private nonisolated func imageFingerprint(for image: NSImage) -> String? { + guard let pngData = encodeToPNG(image) else { return nil } + var hasher = Hasher() + hasher.combine(pngData) + hasher.combine(Int(image.size.width.rounded())) + hasher.combine(Int(image.size.height.rounded())) + return String(hasher.finalize()) + } + + private func encodeBlurredWallpaperPNG(from artwork: NSImage, targetPixelSize: CGSize) -> Data? { + guard let tiffData = artwork.tiffRepresentation, + let sourceImage = CIImage(data: tiffData) + else { return nil } + + let targetRect = CGRect(origin: .zero, size: targetPixelSize) + let sourceExtent = sourceImage.extent + guard sourceExtent.width > 0, sourceExtent.height > 0 else { return nil } + + let scale = max( + targetRect.width / sourceExtent.width, + targetRect.height / sourceExtent.height + ) + let scaledWidth = sourceExtent.width * scale + let scaledHeight = sourceExtent.height * scale + let offsetX = (targetRect.width - scaledWidth) / 2 + let offsetY = (targetRect.height - scaledHeight) / 2 + + let scaledImage = sourceImage + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + .transformed(by: CGAffineTransform(translationX: offsetX, y: offsetY)) + .cropped(to: targetRect) + + let blurredImage = scaledImage + .clampedToExtent() + .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 28]) + .cropped(to: targetRect) + .applyingFilter( + "CIColorControls", + parameters: [ + kCIInputSaturationKey: 0.92, + kCIInputBrightnessKey: -0.03, + kCIInputContrastKey: 0.98 + ] + ) + + let context = CIContext(options: nil) + guard let cgImage = context.createCGImage(blurredImage, from: targetRect) else { return nil } + + let renderedImage = NSImage(cgImage: cgImage, size: NSSize(width: targetPixelSize.width, height: targetPixelSize.height)) + let composedImage = NSImage(size: renderedImage.size) + + composedImage.lockFocus() + renderedImage.draw(in: NSRect(origin: .zero, size: renderedImage.size)) + + NSColor(calibratedWhite: 0.02, alpha: 0.08).setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: renderedImage.size)).fill() + + let gradient = NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.05), + NSColor.clear, + NSColor.black.withAlphaComponent(0.16) + ]) + gradient?.draw(in: NSRect(origin: .zero, size: renderedImage.size), angle: -90) + composedImage.unlockFocus() + + guard let finalTIFF = composedImage.tiffRepresentation, + let finalBitmap = NSBitmapImageRep(data: finalTIFF) + else { return nil } + + return finalBitmap.representation(using: .png, properties: [:]) + } + private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) { liveWallpaperTask?.cancel() liveWallpaperTask = nil - guard isLiveWallpaperAllowed else { return } - guard let artworkFileURL else { return } - - let nextFingerprint = videoURL?.absoluteString - if activeLiveWallpaperFingerprint != nil, activeLiveWallpaperFingerprint != nextFingerprint { - activeLiveWallpaperFingerprint = nil - if applyArtworkToPlist(imageURL: artworkFileURL) { - restartWallpaperAgent() - } + guard isLiveWallpaperAllowed else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + guard let videoURL else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + let nextFingerprint = videoURL.absoluteString + if activeLiveWallpaperFingerprint == nextFingerprint { + scheduleWallpaperTransitionHide(after: .milliseconds(220)) + scheduleHideVideoWindow(after: .milliseconds(220), expectedURL: videoURL) + return } - guard let videoURL else { return } - if activeLiveWallpaperFingerprint == nextFingerprint { return } if let screen = NSScreen.main { showVideoWindow(on: screen, videoURL: videoURL) } + scheduleWallpaperTransitionHide(after: .milliseconds(420)) let assetID = customLiveWallpaperAssetID let manifestURL = aerialManifestURL @@ -321,13 +833,16 @@ final class FullScreenArtworkWindowManager { await MainActor.run { guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + let currentVideoURL = self.isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil guard currentIdentifier == identifier else { return } + guard currentVideoURL?.absoluteString == nextFingerprint else { return } + guard !self.shouldUseSpotifyStaticFallbackWallpaper(videoURL: currentVideoURL) else { return } guard prepared else { return } guard self.applyAerialToPlist(assetID: assetID) else { return } self.activeLiveWallpaperFingerprint = nextFingerprint - self.hideVideoWindow() self.restartWallpaperAgent() + self.scheduleHideVideoWindow(after: .milliseconds(950), expectedURL: videoURL) print("[FullScreenArtworkWindowManager] Live wallpaper applied") } } @@ -526,6 +1041,25 @@ final class FullScreenArtworkWindowManager { // MARK: - Click Receiver + private func installFallbackRightClickMonitorIfNeeded() { + guard fallbackRightClickMonitor == nil else { return } + + fallbackRightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in + guard let self, self.isShowing, self.isShowingSpotifyCanvasFallback else { + return event + } + + self.hide() + return nil + } + } + + private func removeFallbackRightClickMonitor() { + guard let fallbackRightClickMonitor else { return } + NSEvent.removeMonitor(fallbackRightClickMonitor) + self.fallbackRightClickMonitor = nil + } + private func showClickReceiver(on screen: NSScreen) { let screenFrame = screen.frame @@ -575,7 +1109,261 @@ final class FullScreenArtworkWindowManager { clickWindow?.onClick = nil } + private func showArtworkOverlay(on screen: NSScreen, artwork: NSImage, mode: ArtworkOverlayMode) { + currentArtworkOverlayMode = mode + guard let targetFrame = artworkOverlayFrame(on: screen) else { return } + + let window: NSWindow + if let existing = artworkOverlayWindow { + window = existing + } else { + let newWindow = NSWindow( + contentRect: targetFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = false + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + artworkOverlayWindow = newWindow + window = newWindow + artworkOverlayWindowDelegated = false + } + + window.setFrame(targetFrame, display: true) + + let contentView: SpotifyCanvasFallbackArtworkOverlayView + if let existing = window.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView = existing + contentView.frame = NSRect(origin: .zero, size: targetFrame.size) + contentView.updateArtwork(artwork) + } else { + let newContentView = SpotifyCanvasFallbackArtworkOverlayView(artwork: artwork) + newContentView.frame = NSRect(origin: .zero, size: targetFrame.size) + window.contentView = newContentView + contentView = newContentView + } + + contentView.onDismiss = { [weak self] in + self?.hide() + } + + if !artworkOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + artworkOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideArtworkOverlay() { + currentArtworkOverlayMode = .none + if let contentView = artworkOverlayWindow?.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView.onDismiss = nil + } + artworkOverlayWindow?.orderOut(nil) + artworkOverlayWindow?.contentView = nil + } + + private func updateArtworkOverlayFrameIfNeeded() { + guard isShowing, currentArtworkOverlayMode != .none else { return } + guard let window = artworkOverlayWindow else { return } + guard let screen = NSScreen.main, let frame = artworkOverlayFrame(on: screen) else { return } + window.setFrame(frame, display: true) + } + + private func artworkOverlayFrame(on screen: NSScreen) -> NSRect? { + switch currentArtworkOverlayMode { + case .none: + return nil + case .spotifyFallback: + return spotifyFallbackArtworkOverlayFrame(on: screen) + } + } + + private func spotifyFallbackArtworkOverlayFrame(on screen: NSScreen) -> NSRect? { + guard let frames = spotifyCanvasFallbackLayoutFrames(on: screen) else { return nil } + return frames.artworkFrame + } + + private func updateLyricsOverlayIfNeeded(on screen: NSScreen? = NSScreen.main) { + guard isShowing, isShowingSpotifyCanvasFallback, Defaults[.enableLyrics] else { + hideLyricsOverlay() + return + } + + let line = MusicManager.shared.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { + hideLyricsOverlay() + return + } + + guard let screen, let frame = spotifyCanvasFallbackLayoutFrames(on: screen)?.lyricsFrame else { + hideLyricsOverlay() + return + } + + let fontSize = min(max(frame.height * 0.24, 24), 40) + let content = FullScreenLyricsOverlayContent(text: line, fontSize: fontSize) + + let window: NSWindow + let hostingView: NSHostingView + if let existingWindow = lyricsOverlayWindow, + let existingView = existingWindow.contentView as? NSHostingView { + window = existingWindow + hostingView = existingView + } else { + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newHostingView = NSHostingView(rootView: content) + newHostingView.frame = NSRect(origin: .zero, size: frame.size) + newHostingView.wantsLayer = true + newHostingView.layer?.backgroundColor = NSColor.clear.cgColor + newWindow.contentView = newHostingView + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + lyricsOverlayWindow = newWindow + window = newWindow + hostingView = newHostingView + lyricsOverlayWindowDelegated = false + } + + window.setFrame(frame, display: true) + hostingView.frame = NSRect(origin: .zero, size: frame.size) + hostingView.rootView = content + + if !lyricsOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + lyricsOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideLyricsOverlay() { + lyricsOverlayWindow?.orderOut(nil) + } + + private func spotifyCanvasFallbackLayoutFrames(on screen: NSScreen) -> SpotifyCanvasFallbackLayoutFrames? { + let screenFrame = screen.frame + guard let panelFrame = LockScreenPanelManager.shared.latestFrame else { return nil } + + let overlaySide = spotifyCanvasFallbackArtworkSideLength( + screenFrame: screenFrame, + panelSize: panelFrame.size + ) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let targetX = panelFrame.minX - spacing - overlaySide + let targetY = panelFrame.midY - (overlaySide / 2) + let artworkFrame = NSRect(x: targetX, y: targetY, width: overlaySide, height: overlaySide) + let groupFrame = artworkFrame.union(panelFrame) + let lyricsGap = min(max(screenFrame.height * 0.022, 18), 28) + let bottomMargin = min(max(screenFrame.height * 0.16, 140), 220) + let availableHeight = groupFrame.minY - lyricsGap - (screenFrame.minY + bottomMargin) + let desiredHeight = min(max(screenFrame.height * 0.13, 108), 156) + let lyricsHeight = min(desiredHeight, max(availableHeight, 0)) + let lyricsFrame: NSRect? + + if lyricsHeight >= 56 { + lyricsFrame = NSRect( + x: groupFrame.minX, + y: groupFrame.minY - lyricsGap - lyricsHeight, + width: groupFrame.width, + height: lyricsHeight + ) + } else { + lyricsFrame = nil + } + + return SpotifyCanvasFallbackLayoutFrames( + artworkFrame: artworkFrame, + panelFrame: panelFrame, + groupFrame: groupFrame, + lyricsFrame: lyricsFrame + ) + } + + private func showWallpaperTransition(on screen: NSScreen, imageURL: URL) { + guard let image = NSImage(contentsOf: imageURL) else { return } + + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + + let screenFrame = screen.frame + + let window: NSWindow + let view: WallpaperTransitionImageView + + if let existingWindow = wallpaperTransitionWindow, let existingView = wallpaperTransitionView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = WallpaperTransitionImageView(frame: screenFrame) + newWindow.contentView = newView + + wallpaperTransitionWindow = newWindow + wallpaperTransitionView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + view.updateImage(image) + window.orderFrontRegardless() + } + + private func scheduleWallpaperTransitionHide(after delay: Duration) { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + self?.hideWallpaperTransition() + } + } + + private func hideWallpaperTransition() { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + wallpaperTransitionWindow?.orderOut(nil) + } + private func showVideoWindow(on screen: NSScreen, videoURL: URL) { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + let screenFrame = screen.frame let window: NSWindow @@ -610,11 +1398,34 @@ final class FullScreenArtworkWindowManager { window.setFrame(screenFrame, display: true) view.frame = NSRect(origin: .zero, size: screenFrame.size) - view.play(url: videoURL) + if activeVideoWindowURL != videoURL { + view.play(url: videoURL) + activeVideoWindowURL = videoURL + } window.orderFrontRegardless() } + private func scheduleHideVideoWindow(after delay: Duration, expectedURL: URL?) { + videoWindowHideTask?.cancel() + let expectedURLString = expectedURL?.absoluteString + + videoWindowHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self else { return } + + if let expectedURLString, + self.activeVideoWindowURL?.absoluteString != expectedURLString { + return + } + + self.hideVideoWindow() + } + } + private func hideVideoWindow() { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + activeVideoWindowURL = nil videoView?.stop() videoWindow?.orderOut(nil) } @@ -813,12 +1624,27 @@ final class FullScreenArtworkWindowManager { trackChangeCancellable = MusicManager.shared.$songTitle .combineLatest(MusicManager.shared.$artistName) .dropFirst() + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { [weak self] title, artist in guard let self, self.isShowing else { return } if title != self.activeSongTitle || artist != self.activeArtist { - self.hide() + self.refreshPresentationForCurrentTrack() + self.scheduleDeferredTrackRefresh(expectedTitle: title, expectedArtist: artist) } } } + + private func scheduleDeferredTrackRefresh(expectedTitle: String, expectedArtist: String) { + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(700)) + guard let self, self.isShowing else { return } + guard MusicManager.shared.songTitle == expectedTitle, + MusicManager.shared.artistName == expectedArtist + else { return } + + self.refreshPresentationForCurrentTrack() + } + } } diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index c259971..5fab24c 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -58,13 +58,30 @@ class LockScreenPanelManager { return formatter.string(from: Date()) } + private func publishPanelFrame(_ frame: NSRect?) { + latestFrame = frame + + var userInfo: [AnyHashable: Any] = [:] + if let frame { + userInfo["frame"] = NSValue(rect: frame) + } + + NotificationCenter.default.post( + name: .atollLockScreenPanelFrameDidChange, + object: self, + userInfo: userInfo + ) + } + private func registerScreenChangeObservers() { screenChangeObserver = NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main ) { [weak self] _ in - self?.handleScreenGeometryChange(reason: "screen-parameters") + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } } let workspaceCenter = NSWorkspace.shared.notificationCenter @@ -73,7 +90,9 @@ class LockScreenPanelManager { object: nil, queue: .main ) { [weak self] _ in - self?.handleScreenGeometryChange(reason: "screens-did-wake") + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } } workspaceObservers = [wakeObserver] @@ -94,8 +113,9 @@ class LockScreenPanelManager { } let screenFrame = screen.frame - let targetFrame = collapsedFrame(for: screenFrame) - collapsedFrame = targetFrame + let defaultCollapsedFrame = collapsedFrame(for: screenFrame) + let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize) + collapsedFrame = defaultCollapsedFrame isPanelExpanded = false currentAdditionalHeight = 0 @@ -127,14 +147,14 @@ class LockScreenPanelManager { } window.setFrame(targetFrame, display: true) - latestFrame = targetFrame + publishPanelFrame(targetFrame) hideTask?.cancel() panelAnimator.isPresented = false LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) - let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) - hosting.frame = NSRect(origin: .zero, size: targetFrame.size) - hosting.autoresizingMask = [.width, .height] + let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) + hosting.frame = NSRect(origin: .zero, size: targetFrame.size) + hosting.autoresizingMask = [.width, .height] window.contentView = hosting // Ensure the underlying window content is clipped to rounded corners @@ -162,18 +182,17 @@ class LockScreenPanelManager { } func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) { - guard let window = panelWindow, let baseFrame = collapsedFrame else { + guard let window = panelWindow, let screen = currentScreen() else { return } let resizeDuration: CFTimeInterval = 0.28 let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize - let targetWidth = baseSize.width - let targetHeight = baseSize.height + additionalHeight - let originX = baseFrame.midX - (targetWidth / 2) - let originY = baseFrame.origin.y - let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight) + let targetFrame = targetFrame( + for: screen.frame, + panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight) + ) if animated { NSAnimationContext.runAnimationGroup { context in @@ -185,7 +204,7 @@ class LockScreenPanelManager { window.setFrame(targetFrame, display: true) } - latestFrame = targetFrame + publishPanelFrame(targetFrame) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) @@ -229,7 +248,7 @@ class LockScreenPanelManager { guard let window = panelWindow else { print("LockScreenPanelManager: no panel to hide") - latestFrame = nil + publishPanelFrame(nil) return } @@ -239,7 +258,7 @@ class LockScreenPanelManager { await MainActor.run { window?.orderOut(nil) window?.contentView = nil - self.latestFrame = nil + self.publishPanelFrame(nil) print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") } } @@ -267,6 +286,60 @@ class LockScreenPanelManager { .store(in: &cancellables) } + private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect { + let defaultCollapsedFrame = collapsedFrame(for: screenFrame) + + if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback { + return spotifyCanvasFallbackPanelFrame( + screenFrame: screenFrame, + defaultCollapsedFrame: defaultCollapsedFrame, + panelSize: panelSize + ) + } + + let originX = defaultCollapsedFrame.midX - (panelSize.width / 2) + return NSRect( + x: originX, + y: defaultCollapsedFrame.origin.y, + width: panelSize.width, + height: panelSize.height + ) + } + + private func spotifyCanvasFallbackPanelFrame( + screenFrame: NSRect, + defaultCollapsedFrame: NSRect, + panelSize: CGSize + ) -> NSRect { + let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared + let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength( + screenFrame: screenFrame, + panelSize: panelSize + ) + let totalWidth = artworkSide + spacing + panelSize.width + let horizontalMargin: CGFloat = 48 + let minGroupOriginX = screenFrame.minX + horizontalMargin + let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX) + let groupOriginX = min( + max(screenFrame.midX - (totalWidth / 2), minGroupOriginX), + maxGroupOriginX + ) + + let baseCenterY = screenFrame.midY + let largestHalfHeight = max(artworkSide, panelSize.height) / 2 + let minCenterY = screenFrame.minY + largestHalfHeight + 56 + let maxCenterY = screenFrame.maxY - largestHalfHeight - 84 + let centerY = min(max(baseCenterY, minCenterY), maxCenterY) + + return NSRect( + x: groupOriginX + artworkSide + spacing, + y: centerY - (panelSize.height / 2), + width: panelSize.width, + height: panelSize.height + ) + } + private func collapsedFrame(for screenFrame: NSRect) -> NSRect { let collapsedSize = LockScreenMusicPanel.collapsedSize let originX = screenFrame.midX - (collapsedSize.width / 2) @@ -289,3 +362,7 @@ class LockScreenPanelManager { LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main } } + +extension Notification.Name { + static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange") +} diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 1047474..db6ac60 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -38,6 +38,16 @@ struct LyricLine: Identifiable, Codable { } } +private struct LyricsLookupKey: Hashable { + let title: String + let artist: String + let album: String + + var isValid: Bool { + !title.isEmpty && !artist.isEmpty + } +} + let defaultImage: NSImage = .init( systemSymbolName: "heart.fill", accessibilityDescription: "Album Art" @@ -475,6 +485,10 @@ class MusicManager: ObservableObject { // Task used to periodically sync displayed lyric with playback position private var lyricSyncTask: Task? + private var lyricsFetchTask: Task? + private var lyricsFetchKey: LyricsLookupKey? + private var activeLyricsKey: LyricsLookupKey? + private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:] private var explicitLookupTask: Task? private var explicitLookupKey: String? @@ -514,6 +528,13 @@ class MusicManager: ObservableObject { } .store(in: &cancellables) + Defaults.publisher(.enableLyrics) + .receive(on: RunLoop.main) + .sink { [weak self] change in + self?.handleLyricsPreferenceChange(isEnabled: change.newValue) + } + .store(in: &cancellables) + // Observe Pear Desktop launch/terminate for auto-detection setupPearDesktopAutoDetection() @@ -582,6 +603,8 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + lyricsFetchTask?.cancel() + lyricSyncTask?.cancel() explicitLookupTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() @@ -743,7 +766,7 @@ class MusicManager: ObservableObject { self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentURL = state.contentURL - self.fetchLyrics() + self.prepareLyricsForCurrentTrack() if let liveArtworkURL = state.liveArtworkURL { self.videoArtworkURL = liveArtworkURL } else { @@ -1252,53 +1275,108 @@ class MusicManager: ObservableObject { // MARK: - Lyrics Methods func fetchLyrics() { - guard Defaults[.enableLyrics] else { return } - // If the lyrics panel is visible already, provide immediate feedback - if showLyrics { - Task { @MainActor in - self.currentLyrics = "Loading lyrics..." - self.syncedLyrics = [] - self.currentLyricIndex = -1 - } + prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics]) + } + + private func handleLyricsPreferenceChange(isEnabled: Bool) { + showLyrics = isEnabled + + if isEnabled { + prepareLyricsForCurrentTrack(prioritizeVisibleResult: true) + } else { + stopLyricSync() + } + } + + private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) { + guard let lookup = currentLyricsLookupContext() else { + activeLyricsKey = nil + lyricsFetchKey = nil + lyricsFetchTask?.cancel() + lyricsFetchTask = nil + syncedLyrics = [] + currentLyrics = "" + currentLyricIndex = -1 + stopLyricSync() + return } - Task { - do { - let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) - await MainActor.run { - self.syncedLyrics = lyrics - self.currentLyricIndex = -1 - if !lyrics.isEmpty { - self.currentLyrics = lyrics[0].text - } else { - self.currentLyrics = "" - } + let key = lookup.key + let lyricsEnabled = Defaults[.enableLyrics] + let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult + let trackChanged = activeLyricsKey != key + activeLyricsKey = key - // If lyrics are enabled, start syncing them to playback position - if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { - self.startLyricSync() - } else if self.syncedLyrics.isEmpty { - self.stopLyricSync() - } + if trackChanged { + syncedLyrics = [] + currentLyricIndex = -1 + currentLyrics = shouldShowLoading ? "Loading lyrics..." : "" + stopLyricSync() + } + + if !forceFetch, let cachedLyrics = lyricsCache[key] { + applyLyricsToDisplay(cachedLyrics) + return + } + + if lyricsFetchKey == key { + if shouldShowLoading && syncedLyrics.isEmpty { + currentLyrics = "Loading lyrics..." + } + return + } + + lyricsFetchTask?.cancel() + lyricsFetchKey = key + + if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) { + currentLyrics = "Loading lyrics..." + } + + let requestArtist = lookup.requestArtist + let requestTitle = lookup.requestTitle + let requestAlbum = lookup.requestAlbum + + lyricsFetchTask = Task { [weak self] in + guard let self else { return } + + do { + let lyrics = try await self.fetchLyricsFromAPI( + artist: requestArtist, + title: requestTitle, + album: requestAlbum + ) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.activeLyricsKey == key else { return } + self.lyricsCache[key] = lyrics + self.lyricsFetchKey = nil + self.lyricsFetchTask = nil + self.applyLyricsToDisplay(lyrics) } } catch { print("Failed to fetch lyrics: \(error)") await MainActor.run { + guard self.activeLyricsKey == key else { return } + self.lyricsFetchKey = nil + self.lyricsFetchTask = nil self.syncedLyrics = [] - self.currentLyrics = "" self.currentLyricIndex = -1 + self.currentLyrics = lyricsEnabled ? "No lyrics found" : "" self.stopLyricSync() } } } } - private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] { + private func fetchLyricsFromAPI(artist: String, title: String, album: String) async throws -> [LyricLine] { guard !artist.isEmpty, !title.isEmpty else { return [] } // Normalize input and percent-encode let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current) + let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current) guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return [] @@ -1312,7 +1390,8 @@ class MusicManager: ObservableObject { if let http = response as? HTTPURLResponse, http.statusCode == 200 { // Try parse as array JSON (preferred) if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], - let first = jsonArray.first { + let bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) { + let first = bestMatch let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -1354,6 +1433,107 @@ class MusicManager: ObservableObject { } } + private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? { + let requestArtist = normalizedLyricsRequestComponent(artistName) + let requestTitle = normalizedLyricsTitle(songTitle) + let requestAlbum = normalizedLyricsRequestComponent(album) + + let key = LyricsLookupKey( + title: requestTitle.lowercased(), + artist: requestArtist.lowercased(), + album: requestAlbum.lowercased() + ) + + return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil + } + + private func normalizedLyricsRequestComponent(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + } + + private func normalizedLyricsTitle(_ value: String) -> String { + var normalized = normalizedLyricsRequestComponent(value) + let cleanupPatterns = [ + "\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)", + "\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]", + "\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$", + "\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$", + "\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$" + ] + + for pattern in cleanupPatterns { + normalized = normalized.replacingOccurrences( + of: pattern, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } + + return normalizedLyricsRequestComponent(normalized) + } + + private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? { + let normalizedArtist = artist.lowercased() + let normalizedTitle = title.lowercased() + let normalizedAlbum = album.lowercased() + + return results.max { lhs, rhs in + lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum) + < lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum) + } + } + + private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int { + let resultArtist = ((result["artistName"] as? String) ?? "").lowercased() + let resultTitle = ((result["trackName"] as? String) ?? "").lowercased() + let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased() + + var score = 0 + + if resultTitle == title { score += 8 } + else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 } + + if resultArtist == artist { score += 8 } + else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 } + + if !album.isEmpty { + if resultAlbum == album { score += 4 } + else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 } + } + + if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + score += 3 + } + + return score + } + + private func applyLyricsToDisplay(_ lyrics: [LyricLine]) { + syncedLyrics = lyrics + currentLyricIndex = -1 + + guard !lyrics.isEmpty else { + currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : "" + stopLyricSync() + return + } + + let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime) + updateCurrentLyric(for: playbackPosition) + + if currentLyricIndex == -1, let firstLine = lyrics.first?.text { + currentLyrics = firstLine + } + + if Defaults[.enableLyrics] { + startLyricSync() + } else { + stopLyricSync() + } + } + private func parseLRC(_ lrc: String) -> [LyricLine] { let lines = lrc.components(separatedBy: .newlines) var lyrics: [LyricLine] = [] diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4390df2..31fb842 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -913,6 +913,7 @@ extension Defaults.Keys { static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false) static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) + static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") @@ -1197,6 +1198,7 @@ extension Defaults.Keys { // MARK: Lyrics Feature static let enableLyrics = Key("enableLyrics", default: false) + static let showLiveCanvasInDynamicIsland = Key("showLiveCanvasInDynamicIsland", default: false) // MARK: Notes Feature static let enableNotes = Key("enableNotes", default: false)