From 78bf018fe248a2147ed42b44b2c183768ab1a712 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 31 Mar 2026 10:02:17 +0530 Subject: [PATCH 01/18] Atoll: Fix LS Music Widget Overlapping Timers - When lyrics was enabled the music widget would overlap the timers widget causing overlapped widgets which looks awful Signed-off-by: Jis G Jacob --- DynamicIsland/managers/LockScreenTimerWidgetManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index 0337d89..d907c01 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -279,6 +279,11 @@ final class LockScreenTimerWidgetPanelManager { originY = min(originY, topLimit) } + if let musicFrame = LockScreenPanelManager.shared.latestFrame { + let minAllowedY = musicFrame.maxY + 12 + originY = max(originY, minAllowedY) + } + let minY = screen.frame.minY + 100 originY = max(originY, minY) From 5bd84270aae590e5f0ed93331dd7e8ecc457a428 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 16 Apr 2026 16:02:10 +0530 Subject: [PATCH 02/18] Atoll: fullscreen album artwork as lockscreen wallpaper Right-click on the album art in the lock screen music panel to set it as the desktop wallpaper. Right-click again or click anywhere on the background to restore the original wallpaper. Wallpaper also restores automatically on track change or screen unlock. Uses direct plist manipulation of the wallpaper config to avoid leaving traces in the wallpaper picker. Album art PNG is pre-cached in the background on track change for instant application. Adds video artwork URL fetching from iTunes catalog for Apple Music tracks (musicVideo entity search). Signed-off-by: Jis G Jacob --- DynamicIsland.xcodeproj/project.pbxproj | 8 +- DynamicIsland/Localizable.xcstrings | 75 +- .../MediaControllers/SpotifyController.swift | 428 ++++++++- .../LockScreen/LockScreenMusicPanel.swift | 139 ++- .../components/Settings/SettingsView.swift | 5 + .../Settings/SpotifyAuthSettingsSection.swift | 112 +++ .../FullScreenArtworkWindowManager.swift | 824 ++++++++++++++++++ .../managers/LockScreenManager.swift | 1 + DynamicIsland/managers/MusicManager.swift | 86 +- .../managers/SpotifyAuthManager.swift | 343 ++++++++ DynamicIsland/models/Constants.swift | 6 + DynamicIsland/models/PlaybackState.swift | 2 + 12 files changed, 1946 insertions(+), 83 deletions(-) create mode 100644 DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift create mode 100644 DynamicIsland/managers/FullScreenArtworkWindowManager.swift create mode 100644 DynamicIsland/managers/SpotifyAuthManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 02e0162..3d4e01b 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -454,12 +454,12 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = 9Y64TRM77N; + DEVELOPMENT_TEAM = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -526,13 +526,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + "DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 03c4a70..2273b82 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,7 +2314,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -2438,6 +2437,10 @@ } } }, + "1. Open Spotify in a browser and log in" : { + "comment" : "A step in the process of getting a Spotify cookie.", + "isCommentAutoGenerated" : true + }, "1m" : { "localizations" : { "ko" : { @@ -2511,6 +2514,10 @@ } } }, + "2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com" : { + "comment" : "A step to copy the Spotify cookie from the developer tools.", + "isCommentAutoGenerated" : true + }, "3 items" : { "comment" : "merge:ko:alt='항목 3개'", "localizations" : { @@ -2546,6 +2553,10 @@ } } }, + "3. Copy the value of `sp_dc` and paste it here" : { + "comment" : "A step to paste the Spotify cookie into the app.", + "isCommentAutoGenerated" : true + }, "5 colors" : { "comment" : "merge:ko:alt='5가지 색상'", "localizations" : { @@ -3138,7 +3149,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3696,7 +3706,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5623,6 +5632,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6016,7 +6029,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6386,6 +6398,10 @@ } } }, + "Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track." : { + "comment" : "A description of how Spotify cookies are used by Atoll.", + "isCommentAutoGenerated" : true + }, "Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : { "comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.", "isCommentAutoGenerated" : true, @@ -6920,7 +6936,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -9952,7 +9967,6 @@ } }, "Clear" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -14521,7 +14535,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20829,7 +20842,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21574,6 +21586,10 @@ } } }, + "How to get it:" : { + "comment" : "A heading for the instructions on how to get the Spotify cookie.", + "isCommentAutoGenerated" : true + }, "https://example.com/animation.json" : { "comment" : "A placeholder text for a Lottie JSON URL input field.", "isCommentAutoGenerated" : true, @@ -26270,6 +26286,10 @@ } } }, + "Method source" : { + "comment" : "A link to the source code of Spotify's method for requesting a Spotify web player token.", + "isCommentAutoGenerated" : true + }, "Mic" : { "comment" : "Name of a sneak action type that controls the microphone.", "isCommentAutoGenerated" : true, @@ -29944,7 +29964,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30436,6 +30455,10 @@ } } }, + "Open Spotify Web Player" : { + "comment" : "A link to open Spotify.", + "isCommentAutoGenerated" : true + }, "Open System Settings" : { "localizations" : { "ko" : { @@ -30690,7 +30713,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30791,6 +30813,14 @@ } } }, + "Paste from Clipboard" : { + "comment" : "A button that copies the `sp_dc` cookie from the clipboard.", + "isCommentAutoGenerated" : true + }, + "Paste the `sp_dc` cookie from your logged-in Spotify web session." : { + "comment" : "A description of how to paste the Spotify cookie.", + "isCommentAutoGenerated" : true + }, "Pause" : { "localizations" : { "ru" : { @@ -34233,7 +34263,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35755,7 +35784,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38188,7 +38216,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38213,7 +38240,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38441,7 +38467,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39034,6 +39059,10 @@ } } }, + "sp_dc cookie" : { + "comment" : "A label for the Spotify `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, "Speed" : { "localizations" : { "ar" : { @@ -39165,7 +39194,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39188,6 +39216,10 @@ } } }, + "Spotify Canvas Session" : { + "comment" : "A section header for the Spotify Canvas session settings.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -39402,7 +39434,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42230,7 +42261,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -43892,6 +43922,14 @@ }, "shouldTranslate" : false }, + "Validate Cookie" : { + "comment" : "A button that validates a Spotify session cookie.", + "isCommentAutoGenerated" : true + }, + "Validating..." : { + "comment" : "The text of a button that is shown while Spotify's session is being validated.", + "isCommentAutoGenerated" : true + }, "Variant %lld" : { "comment" : "A label for a specific variant of the Liquid Glass.", "isCommentAutoGenerated" : true, @@ -45595,7 +45633,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 0380d96..83d09e1 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -20,8 +20,8 @@ * along with this program. If not, see . */ -import Foundation import Combine +import Foundation import SwiftUI class SpotifyController: MediaControllerProtocol { @@ -31,91 +31,117 @@ class SpotifyController: MediaControllerProtocol { @Published private var playbackState: PlaybackState = PlaybackState( bundleIdentifier: SpotifyController.bundleIdentifier ) - + var playbackStatePublisher: AnyPublisher { $playbackState.eraseToAnyPublisher() } - + var isWorking: Bool { - return true // Spotify controller always works + true } - + private var notificationTask: Task? - + private var sessionChangeCancellable: AnyCancellable? + // Constant for time between command and update private let commandUpdateDelay: Duration = .milliseconds(25) private var lastArtworkURL: String? private var artworkFetchTask: Task? - + private var canvasFetchTask: Task? + private var currentCanvasTrackURI: String? + private var lastCanvasRequestTrackURI: String? + private var lastCanvasRequestDate: Date = .distantPast + private let canvasResolver = SpotifyCanvasResolver() + init() { setupPlaybackStateChangeObserver() + setupSessionChangeObserver() Task { if isActive() { await updatePlaybackInfo() } } } - + private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in let notifications = DistributedNotificationCenter.default().notifications( named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") ) - + for await _ in notifications { await self?.updatePlaybackInfo() } } } - + + private func setupSessionChangeObserver() { + sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange) + .sink { [weak self] _ in + self?.lastCanvasRequestTrackURI = nil + self?.lastCanvasRequestDate = .distantPast + if let self { + var updatedState = self.playbackState + updatedState.liveArtworkURL = nil + self.playbackState = updatedState + } + Task { + await self?.updatePlaybackInfo() + } + } + } + deinit { notificationTask?.cancel() artworkFetchTask?.cancel() + canvasFetchTask?.cancel() + sessionChangeCancellable?.cancel() } - + // MARK: - Protocol Implementation func play() async { await executeCommand("play") } func pause() async { await executeCommand("pause") } func togglePlay() async { await executeCommand("playpause") } func nextTrack() async { await executeCommand("next track") } - + func previousTrack() async { await executeAndRefresh("previous track") } - + func seek(to time: Double) async { await executeAndRefresh("set player position to \(time)") } - + func toggleShuffle() async { await executeAndRefresh("set shuffling to not shuffling") } - + func toggleRepeat() async { await executeAndRefresh("set repeating to not repeating") } - + func isActive() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } } - + func updatePlaybackInfo() async { guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard descriptor.numberOfItems >= 11 else { return } - + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 - let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000 + let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000 let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" - let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - + let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? "" + let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? "" + let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString) + var state = PlaybackState( bundleIdentifier: Self.bundleIdentifier, isPlaying: isPlaying, @@ -129,14 +155,30 @@ class SpotifyController: MediaControllerProtocol { repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier - state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL + state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines) if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { state.artwork = existingArtwork } - playbackState = state + if trackURI == currentCanvasTrackURI { + state.liveArtworkURL = playbackState.liveArtworkURL + } else { + currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI + canvasFetchTask?.cancel() + canvasFetchTask = nil + } + + playbackState = state + + if !trackURI.isEmpty { + scheduleCanvasFetchIfNeeded(for: trackURI) + } if !artworkURL.isEmpty, let url = URL(string: artworkURL) { guard artworkURL != lastArtworkURL || state.artwork == nil else { return } @@ -152,6 +194,7 @@ class SpotifyController: MediaControllerProtocol { guard let self = self else { return } var updatedState = currentState updatedState.artwork = data + updatedState.liveArtworkURL = self.playbackState.liveArtworkURL self.playbackState = updatedState self.lastArtworkURL = artworkURL self.artworkFetchTask = nil @@ -164,9 +207,43 @@ class SpotifyController: MediaControllerProtocol { } } } - -// MARK: - Private Methods - + + // MARK: - Private Methods + + private func scheduleCanvasFetchIfNeeded(for trackURI: String) { + guard !trackURI.isEmpty else { return } + guard playbackState.liveArtworkURL == nil else { return } + guard canvasFetchTask == nil else { return } + + if lastCanvasRequestTrackURI == trackURI, + Date().timeIntervalSince(lastCanvasRequestDate) < 5 + { + return + } + + lastCanvasRequestTrackURI = trackURI + lastCanvasRequestDate = Date() + + canvasFetchTask = Task { [weak self] in + guard let self else { return } + + let canvasURL = await self.canvasResolver.canvasURL(for: trackURI) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.currentCanvasTrackURI == trackURI else { + self.canvasFetchTask = nil + return + } + + var updatedState = self.playbackState + updatedState.liveArtworkURL = canvasURL + self.playbackState = updatedState + self.canvasFetchTask = nil + } + } + } + private func executeCommand(_ command: String) async { let script = "tell application \"Spotify\" to \(command)" try? await AppleScriptHelper.executeVoid(script) @@ -177,7 +254,7 @@ class SpotifyController: MediaControllerProtocol { try? await Task.sleep(for: commandUpdateDelay) await updatePlaybackInfo() } - + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Spotify" @@ -192,21 +269,294 @@ class SpotifyController: MediaControllerProtocol { set shuffleState to shuffling set repeatState to repeating set artworkURL to artwork url of current track - set trackIdentifier to "" - try - set trackIdentifier to id of current track - end try - set trackSpotifyURL to "" - try - set trackSpotifyURL to spotify url of current track - end try - return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL} + set trackID to id of current track + set spotifyURL to spotify url of current track + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL} on error return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} end try end tell """ - + return try await AppleScriptHelper.execute(script) } + + private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String { + let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedIdentifier.hasPrefix("spotify:track:") { + return trimmedIdentifier + } + + if isLikelySpotifyTrackID(trimmedIdentifier) { + return "spotify:track:\(trimmedIdentifier)" + } + + if let trackID = extractTrackID(from: spotifyURLString) { + return "spotify:track:\(trackID)" + } + + return "" + } + + private static func extractTrackID(from spotifyURLString: String) -> String? { + guard let url = URL(string: spotifyURLString), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + + let pathComponents = components.path.split(separator: "/") + guard let trackIndex = pathComponents.firstIndex(of: "track"), + trackIndex + 1 < pathComponents.count + else { return nil } + + let identifier = String(pathComponents[trackIndex + 1]) + return isLikelySpotifyTrackID(identifier) ? identifier : nil + } + + private static func isLikelySpotifyTrackID(_ value: String) -> Bool { + value.count == 22 && value.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) + } + } +} + +private actor SpotifyCanvasResolver { + private struct CacheEntry { + let url: URL? + let expiresAt: Date + } + + private var cache: [String: CacheEntry] = [:] + + func canvasURL(for trackURI: String) async -> URL? { + let now = Date() + if let entry = cache[trackURI], entry.expiresAt > now { + return entry.url + } + + let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true) + let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600 + cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime)) + return resolvedURL + } + + private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? { + guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else { + return nil + } + + var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!) + request.httpMethod = "POST" + request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI) + request.setValue("application/protobuf", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("en", forHTTPHeaderField: "Accept-Language") + request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + + if httpResponse.statusCode == 401, retryOnUnauthorized { + await SpotifyAuthManager.shared.invalidateAccessToken() + return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false) + } + + guard (200..<300).contains(httpResponse.statusCode) else { + return nil + } + + return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI) + } catch { + return nil + } + } +} + +private enum SpotifyCanvasProtobuf { + static func makeCanvasRequest(trackURI: String) -> Data { + let uriData = Data(trackURI.utf8) + + var trackMessage = Data() + trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2)) + trackMessage.append(varint(for: uriData.count)) + trackMessage.append(uriData) + + var request = Data() + request.append(protobufKey(fieldNumber: 1, wireType: 2)) + request.append(varint(for: trackMessage.count)) + request.append(trackMessage) + return request + } + + static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? { + do { + var reader = ProtobufReader(data: data) + var matchingURLs: [URL] = [] + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (1, 2): + let canvasData = try reader.readLengthDelimited() + if let record = try parseCanvasRecord(from: canvasData), + record.trackURI == trackURI, + let canvasURL = record.canvasURL + { + matchingURLs.append(canvasURL) + } + default: + try reader.skipField(wireType: wireType) + } + } + + return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first + } catch { + return nil + } + } + + private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? { + var reader = ProtobufReader(data: data) + var trackURI = "" + var canvasURL: URL? + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (2, 2): + let urlString = try reader.readString() + canvasURL = URL(string: urlString) + case (5, 2): + trackURI = try reader.readString() + default: + try reader.skipField(wireType: wireType) + } + } + + guard !trackURI.isEmpty else { return nil } + return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL) + } + + private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data { + var key = Data() + key.append(varint(for: (fieldNumber << 3) | wireType)) + return key + } + + private static func varint(for value: Int) -> Data { + var remaining = UInt64(value) + var buffer = Data() + + repeat { + var nextByte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { + nextByte |= 0x80 + } + buffer.append(nextByte) + } while remaining != 0 + + return buffer + } +} + +private struct SpotifyCanvasRecord { + let trackURI: String + let canvasURL: URL? +} + +private struct ProtobufReader { + enum ReaderError: Error { + case malformedVarint + case unexpectedEnd + case unsupportedWireType + } + + private let data: Data + private var index: Data.Index + + init(data: Data) { + self.data = data + self.index = data.startIndex + } + + var isAtEnd: Bool { + index >= data.endIndex + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while shift < 64 { + guard index < data.endIndex else { + throw ReaderError.unexpectedEnd + } + + let byte = data[index] + index = data.index(after: index) + + result |= UInt64(byte & 0x7F) << shift + if (byte & 0x80) == 0 { + return result + } + + shift += 7 + } + + throw ReaderError.malformedVarint + } + + mutating func readLengthDelimited() throws -> Data { + let length = Int(try readVarint()) + guard length >= 0 else { + throw ReaderError.unexpectedEnd + } + + let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd + } + + let slice = data[index.. String { + let bytes = try readLengthDelimited() + return String(decoding: bytes, as: UTF8.self) + } + + mutating func skipField(wireType: Int) throws { + switch wireType { + case 0: + _ = try readVarint() + case 1: + try advance(by: 8) + case 2: + _ = try readLengthDelimited() + case 5: + try advance(by: 4) + default: + throw ReaderError.unsupportedWireType + } + } + + private mutating func advance(by count: Int) throws { + let endIndex = data.index(index, offsetBy: count, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd + } + index = endIndex + } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 17716a4..4d6fbfd 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -46,6 +46,7 @@ struct LockScreenMusicPanel: View { @State private var isExpanded = false @State private var isVolumeSliderVisible = false @State private var isAirPlayPopoverPresented = false + @State private var isArtworkFullscreen = false @State private var collapseWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem? @@ -66,6 +67,8 @@ struct LockScreenMusicPanel: View { @Default(.enableLyrics) private var enableLyrics @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled + @Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork init(animator: LockScreenPanelAnimator) { _animator = ObservedObject(wrappedValue: animator) @@ -163,6 +166,11 @@ struct LockScreenMusicPanel: View { parallaxResumeWorkItem = nil isParallaxSuspended = false } + .onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + } .onChange(of: isExpanded) { _, expanded in updatePanelSize() } @@ -329,30 +337,43 @@ struct LockScreenMusicPanel: View { } private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { - Button(action: toggleExpanded) { - ZStack(alignment: .bottomTrailing) { - albumArtImage(size: size, cornerRadius: cornerRadius) - if showAppIcon, let icon = lockScreenAppIcon { - icon - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: appIconSize, height: appIconSize) - - .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) - .offset(x: appIconOffset, y: appIconOffset) - .transition(.scale.combined(with: .opacity)) - } + ZStack(alignment: .bottomTrailing) { + if isArtworkFullscreen { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.white.opacity(0.06)) + .overlay( + Image(systemName: "photo.fill") + .font(.system(size: size * 0.3, weight: .light)) + .foregroundColor(.white.opacity(0.2)) + ) + } else { + albumArtImage(size: size, cornerRadius: cornerRadius) + if showAppIcon, let icon = lockScreenAppIcon { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: appIconSize, height: appIconSize) + .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) + .offset(x: appIconOffset, y: appIconOffset) + .transition(.scale.combined(with: .opacity)) } - .albumArtFlip(angle: musicManager.flipAngle) - .parallax3D(enableOverride: lockScreenParallaxEnabled, 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)) + } } - .buttonStyle(PlainButtonStyle()) + .albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle) + .parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, 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)) .opacity(musicManager.isPlaying ? 1 : 0.4) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) + .animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen) + .onTapGesture { + toggleExpanded() + } + .onRightClick { + expandArtworkToFullscreen() + } } @ViewBuilder @@ -384,6 +405,41 @@ struct LockScreenMusicPanel: View { } } + private func expandArtworkToFullscreen() { + guard fullscreenArtworkEnabled else { return } + + if FullScreenArtworkWindowManager.shared.isShowing { + FullScreenArtworkWindowManager.shared.hide() + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + return + } + + guard musicManager.hasActiveSession else { return } + + let artwork = musicManager.albumArt + let videoURL = musicManager.videoArtworkURL + + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = true + } + + FullScreenArtworkWindowManager.shared.onDismiss = { + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.28)) { + NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil) + } + } + } + + FullScreenArtworkWindowManager.shared.show( + artwork: artwork, + videoURL: fullscreenVideoArtwork ? videoURL : nil, + allowLiveWallpaper: fullscreenVideoArtwork + ) + } + private func registerInteraction() { cancelCollapseTimer() guard isExpanded else { return } @@ -1384,3 +1440,48 @@ private struct GlassTextBackdrop: View { .accessibilityHidden(true) } } + +// MARK: - Right Click Gesture + +private struct RightClickOverlay: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + content.overlay( + RightClickReceiverView(action: action) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +private struct RightClickReceiverView: NSViewRepresentable { + let action: () -> Void + + func makeNSView(context: Context) -> RightClickNSView { + let view = RightClickNSView() + view.onRightClick = action + return view + } + + func updateNSView(_ nsView: RightClickNSView, context: Context) { + nsView.onRightClick = action + } +} + +private extension View { + func onRightClick(perform action: @escaping () -> Void) -> some View { + modifier(RightClickOverlay(action: action)) + } +} + +final class RightClickNSView: NSView { + var onRightClick: (() -> Void)? + + override func rightMouseDown(with event: NSEvent) { + onRightClick?() + } +} + +extension Notification.Name { + static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed") +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 38302ff..b44c3d2 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2505,6 +2505,11 @@ struct Media: View { .font(.caption) } } + + if mediaController == .spotify { + SpotifyAuthSettingsSection() + } + Section { Defaults.Toggle(key: .showStandardMediaControls) { Text("Show media controls in Dynamic Island") diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift new file mode 100644 index 0000000..28e7b55 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -0,0 +1,112 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SwiftUI + +struct SpotifyAuthSettingsSection: View { + @Default(.spotifySPDCCookie) private var spotifySPDCCookie + @ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared + + private var hasCookie: Bool { + !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty + } + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .lineLimit(2...4) + .textSelection(.enabled) + } + + HStack(spacing: 10) { + Circle() + .fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary)) + .frame(width: 8, height: 8) + + Text(spotifyAuthManager.sessionStatusText) + .foregroundStyle(.secondary) + } + + if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty { + Text(authErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Paste from Clipboard") { + pasteCookieFromClipboard() + } + + Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") { + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie) + Task { + await spotifyAuthManager.validateSession() + } + } + .disabled(!hasCookie || spotifyAuthManager.isAuthorizing) + + Button("Clear") { + spotifySPDCCookie = "" + spotifyAuthManager.clearSession() + } + .disabled(!hasCookie && !spotifyAuthManager.isAuthenticated) + } + + VStack(alignment: .leading, spacing: 6) { + Text("How to get it:") + .font(.caption) + .foregroundStyle(.secondary) + + Text("1. Open Spotify in a browser and log in") + .font(.caption) + Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") + .font(.caption) + Text("3. Copy the value of `sp_dc` and paste it here") + .font(.caption) + + HStack(spacing: 12) { + Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) + Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) + } + .font(.caption) + } + .padding(.top, 2) + } header: { + Text("Spotify Canvas Session") + } footer: { + Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + + private func pasteCookieFromClipboard() { + guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return } + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText) + } +} diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift new file mode 100644 index 0000000..c94a924 --- /dev/null +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -0,0 +1,824 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import AVFoundation +import Combine +import SkyLightWindow +import SwiftUI + +// MARK: - Click Receiver Window + +private class ClickReceiverWindow: NSWindow { + var onClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + onClick?() + } + + override func rightMouseDown(with event: NSEvent) { + onClick?() + } +} + +private final class LoopingVideoView: NSView { + private var playerLayer: AVPlayerLayer? + private var queuePlayer: AVQueuePlayer? + private var playerLooper: AVPlayerLooper? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + override func layout() { + super.layout() + playerLayer?.frame = bounds + } + + func play(url: URL) { + stop() + + let playerItem = AVPlayerItem(url: url) + let player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + + let layer = AVPlayerLayer(player: player) + layer.videoGravity = .resizeAspectFill + layer.frame = bounds + layer.backgroundColor = NSColor.clear.cgColor + + self.layer?.sublayers?.forEach { $0.removeFromSuperlayer() } + self.layer?.addSublayer(layer) + + playerLayer = layer + queuePlayer = player + playerLooper = AVPlayerLooper(player: player, templateItem: playerItem) + player.play() + } + + func stop() { + queuePlayer?.pause() + playerLooper = nil + queuePlayer = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + } +} + +// MARK: - Window Manager + +@MainActor +final class FullScreenArtworkWindowManager { + static let shared = FullScreenArtworkWindowManager() + + private(set) var isShowing = false + var onDismiss: (() -> Void)? + + private let wallpaperPlistURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist") + }() + private let backupPlistURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_wallpaper_backup.plist") + }() + private let aerialManifestURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json") + }() + private let aerialVideosDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/videos", isDirectory: true) + }() + private let aerialThumbnailsDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/thumbnails", isDirectory: true) + }() + private let customLiveWallpaperAssetID = "6D6834A4-2F0F-479A-B053-7D4DC5CB8EB7" + private let liveWallpaperManifestBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_manifest_backup.json") + }() + private let liveWallpaperVideoBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_video_backup.mov") + }() + private let liveWallpaperThumbnailBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_thumbnail_backup.png") + }() + private var artworkFileURL: URL? + private var cachedArtworkPNG: URL? + private var cachedArtworkIdentifier: String? + private var activeSongTitle: String? + private var activeArtist: String? + private var trackChangeCancellable: AnyCancellable? + private var artworkCacheCancellable: AnyCancellable? + private var videoArtworkCancellable: AnyCancellable? + private var clickWindow: ClickReceiverWindow? + private var clickWindowDelegated = false + private var videoWindow: NSWindow? + private var videoView: LoopingVideoView? + private var liveWallpaperTask: Task? + private var isLiveWallpaperAllowed = false + private var activeLiveWallpaperFingerprint: String? + + private init() { + observeArtworkChanges() + observeVideoArtworkChanges() + } + + 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 + + observeTrackChanges() + scheduleLiveWallpaperPreparation(for: videoURL, artwork: artwork, identifier: artworkID) + + print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") + } + + func hide() { + guard isShowing else { return } + isShowing = false + isLiveWallpaperAllowed = false + activeLiveWallpaperFingerprint = nil + trackChangeCancellable?.cancel() + trackChangeCancellable = nil + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + + hideVideoWindow() + hideClickReceiver() + restoreWallpaper() + + activeSongTitle = nil + activeArtist = nil + + let callback = onDismiss + onDismiss = nil + callback?() + + print("[FullScreenArtworkWindowManager] Original wallpaper restored") + } + + // MARK: - Artwork Pre-Cache + + private func observeArtworkChanges() { + artworkCacheCancellable = MusicManager.shared.$albumArt + .debounce(for: .milliseconds(500), 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 } + 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 + } + } + } + } + + private func observeVideoArtworkChanges() { + videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .receive(on: DispatchQueue.main) + .sink { [weak self] videoURL 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 + ) + } + } + + private nonisolated func encodeToPNG(_ image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) + else { return nil } + return bitmap.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 let videoURL else { return } + if activeLiveWallpaperFingerprint == nextFingerprint { return } + if let screen = NSScreen.main { + showVideoWindow(on: screen, videoURL: videoURL) + } + + let assetID = customLiveWallpaperAssetID + let manifestURL = aerialManifestURL + let videosDirectoryURL = aerialVideosDirectoryURL + let thumbnailsDirectoryURL = aerialThumbnailsDirectoryURL + let manifestBackupURL = liveWallpaperManifestBackupURL + let videoBackupURL = liveWallpaperVideoBackupURL + let thumbnailBackupURL = liveWallpaperThumbnailBackupURL + let thumbnailData = encodeToPNG(artwork) + let title = activeSongTitle ?? MusicManager.shared.songTitle + let artist = activeArtist ?? MusicManager.shared.artistName + let displayName = [title, artist] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " - ") + + liveWallpaperTask = Task(priority: .utility) { [weak self] in + let prepared = await Self.prepareCustomLiveWallpaperAsset( + from: videoURL, + assetID: assetID, + displayName: displayName.isEmpty ? "Atoll Canvas" : displayName, + manifestURL: manifestURL, + videosDirectoryURL: videosDirectoryURL, + thumbnailsDirectoryURL: thumbnailsDirectoryURL, + thumbnailData: thumbnailData, + manifestBackupURL: manifestBackupURL, + videoBackupURL: videoBackupURL, + thumbnailBackupURL: thumbnailBackupURL + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard currentIdentifier == identifier else { return } + guard prepared else { return } + guard self.applyAerialToPlist(assetID: assetID) else { return } + + self.activeLiveWallpaperFingerprint = nextFingerprint + self.hideVideoWindow() + self.restartWallpaperAgent() + print("[FullScreenArtworkWindowManager] Live wallpaper applied") + } + } + } + + private nonisolated static func prepareCustomLiveWallpaperAsset( + from sourceURL: URL, + assetID: String, + displayName: String, + manifestURL: URL, + videosDirectoryURL: URL, + thumbnailsDirectoryURL: URL, + thumbnailData: Data?, + manifestBackupURL: URL, + videoBackupURL: URL, + thumbnailBackupURL: URL + ) async -> Bool { + let fm = FileManager.default + let videoDestinationURL = videosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = thumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + do { + try fm.createDirectory(at: videosDirectoryURL, withIntermediateDirectories: true) + try fm.createDirectory(at: thumbnailsDirectoryURL, withIntermediateDirectories: true) + } catch { + return false + } + + backupItemIfNeeded(at: manifestURL, backupURL: manifestBackupURL) + backupItemIfNeeded(at: videoDestinationURL, backupURL: videoBackupURL) + backupItemIfNeeded(at: thumbnailDestinationURL, backupURL: thumbnailBackupURL) + + guard await materializeVideo(from: sourceURL, to: videoDestinationURL) else { + return false + } + + if let thumbnailData { + try? thumbnailData.write(to: thumbnailDestinationURL, options: .atomic) + } + + return updateAerialManifest( + manifestURL: manifestURL, + assetID: assetID, + videoURL: videoDestinationURL, + thumbnailURL: fm.fileExists(atPath: thumbnailDestinationURL.path) ? thumbnailDestinationURL : nil, + displayName: displayName + ) + } + + private nonisolated static func materializeVideo(from sourceURL: URL, to destinationURL: URL) async -> Bool { + let fm = FileManager.default + try? fm.removeItem(at: destinationURL) + + if !sourceURL.isFileURL { + guard let downloadedURL = await downloadRemoteVideo(from: sourceURL) else { + return false + } + defer { try? fm.removeItem(at: downloadedURL) } + return await materializeVideo(from: downloadedURL, to: destinationURL) + } + + if sourceURL.isFileURL, sourceURL.pathExtension.lowercased() == "mov" { + do { + try fm.copyItem(at: sourceURL, to: destinationURL) + return validateVideo(at: destinationURL) + } catch { + return false + } + } + + let asset = AVURLAsset(url: sourceURL) + let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality] + for preset in presets { + try? fm.removeItem(at: destinationURL) + guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else { + continue + } + + exportSession.outputURL = destinationURL + exportSession.outputFileType = .mov + exportSession.shouldOptimizeForNetworkUse = true + + let exported = await export(exportSession) + if exported, validateVideo(at: destinationURL) { + return true + } + } + + return false + } + + private nonisolated static func downloadRemoteVideo(from sourceURL: URL) async -> URL? { + do { + let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + return nil + } + + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + return destinationURL + } catch { + return nil + } + } + + private nonisolated static func export(_ exportSession: AVAssetExportSession) async -> Bool { + await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume(returning: exportSession.status == .completed) + } + } + } + + private nonisolated static func validateVideo(at url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let asset = AVURLAsset(url: url) + return !asset.tracks(withMediaType: .video).isEmpty + } + + private nonisolated static func updateAerialManifest( + manifestURL: URL, + assetID: String, + videoURL: URL, + thumbnailURL: URL?, + displayName: String + ) -> Bool { + guard let manifestData = try? Data(contentsOf: manifestURL), + var root = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + var assets = root["assets"] as? [[String: Any]] + else { + return false + } + + guard let existingIndex = assets.firstIndex(where: { ($0["id"] as? String) == assetID }) else { + return false + } + + var customAsset = assets[existingIndex] + customAsset["accessibilityLabel"] = displayName + customAsset["previewImage"] = thumbnailURL?.absoluteString ?? "" + customAsset["url-4K-SDR-240FPS"] = videoURL.absoluteString + customAsset["pointsOfInterest"] = [:] + assets[existingIndex] = customAsset + + root["assets"] = assets + + guard let encoded = try? JSONSerialization.data(withJSONObject: root, options: []) else { + return false + } + + do { + try encoded.write(to: manifestURL, options: .atomic) + return true + } catch { + return false + } + } + + private nonisolated static func backupItemIfNeeded(at sourceURL: URL, backupURL: URL) { + let fm = FileManager.default + guard fm.fileExists(atPath: sourceURL.path) else { return } + guard !fm.fileExists(atPath: backupURL.path) else { return } + try? fm.copyItem(at: sourceURL, to: backupURL) + } + + private func restoreLiveWallpaperResources() { + let fm = FileManager.default + let assetID = customLiveWallpaperAssetID + let videoDestinationURL = aerialVideosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = aerialThumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + if fm.fileExists(atPath: liveWallpaperManifestBackupURL.path) { + try? fm.removeItem(at: aerialManifestURL) + try? fm.copyItem(at: liveWallpaperManifestBackupURL, to: aerialManifestURL) + try? fm.removeItem(at: liveWallpaperManifestBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperVideoBackupURL.path) { + try? fm.removeItem(at: videoDestinationURL) + try? fm.copyItem(at: liveWallpaperVideoBackupURL, to: videoDestinationURL) + try? fm.removeItem(at: liveWallpaperVideoBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperThumbnailBackupURL.path) { + try? fm.removeItem(at: thumbnailDestinationURL) + try? fm.copyItem(at: liveWallpaperThumbnailBackupURL, to: thumbnailDestinationURL) + try? fm.removeItem(at: liveWallpaperThumbnailBackupURL) + } + } + + // MARK: - Click Receiver + + private func showClickReceiver(on screen: NSScreen) { + let screenFrame = screen.frame + + let window: ClickReceiverWindow + if let existing = clickWindow { + window = existing + } else { + let newWindow = ClickReceiverWindow( + contentRect: screenFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.isMovable = false + newWindow.hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + clickWindow = newWindow + window = newWindow + clickWindowDelegated = false + } + + window.setFrame(screenFrame, display: true) + window.onClick = { [weak self] in + self?.hide() + } + + if !clickWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + clickWindowDelegated = true + } + + window.orderFrontRegardless() + + for other in NSApp.windows where other !== window && other.level.rawValue >= Int(CGShieldingWindowLevel()) && other.isVisible { + window.order(.below, relativeTo: other.windowNumber) + } + } + + private func hideClickReceiver() { + clickWindow?.orderOut(nil) + clickWindow?.onClick = nil + } + + private func showVideoWindow(on screen: NSScreen, videoURL: URL) { + let screenFrame = screen.frame + + let window: NSWindow + let view: LoopingVideoView + + if let existingWindow = videoWindow, let existingView = videoView { + 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 = LoopingVideoView(frame: screenFrame) + newWindow.contentView = newView + + videoWindow = newWindow + videoView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + view.play(url: videoURL) + window.orderFrontRegardless() + } + + private func hideVideoWindow() { + videoView?.stop() + videoWindow?.orderOut(nil) + } + + // MARK: - Backup / Restore + + private var hasValidBackup: Bool { + FileManager.default.fileExists(atPath: backupPlistURL.path) + } + + private func backupWallpaperConfig() { + guard !hasValidBackup else { return } + try? FileManager.default.copyItem(at: wallpaperPlistURL, to: backupPlistURL) + } + + private func applyArtworkToPlist(imageURL: URL) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopBlock = makeImageWallpaperBlock(imageURL: imageURL) + let idleBlock = makeImageWallpaperBlock(imageURL: imageURL) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func applyAerialToPlist(assetID: String) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopTemplate = wallpaperBlock( + named: "Desktop", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + let idleTemplate = wallpaperBlock( + named: "Idle", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + + let desktopBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: desktopTemplate) + let idleBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: idleTemplate) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func wallpaperBlock(named name: String, in primary: [String: Any]?, fallback: [String: Any]?) -> [String: Any]? { + if let primaryBlock = primary?[name] as? [String: Any] { + return primaryBlock + } + return fallback?[name] as? [String: Any] + } + + private func makeImageWallpaperBlock(imageURL: URL) -> [String: Any] { + let config: [String: Any] = [ + "type": "imageFile", + "url": ["relative": imageURL.absoluteString] + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let imageChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.image", + "Files": [] as [Any], + "Configuration": configData + ] + + let contentBlock: [String: Any] = [ + "Choices": [imageChoice], + "Shuffle": "$null" + ] + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func makeAerialWallpaperBlock(assetID: String, existingBlock: [String: Any]?) -> [String: Any] { + let config: [String: Any] = [ + "assetID": assetID + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let aerialChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.aerials", + "Files": [] as [Any], + "Configuration": configData + ] + + var contentBlock: [String: Any] = [ + "Choices": [aerialChoice], + "Shuffle": "$null" + ] + + if let existingContent = existingBlock?["Content"] as? [String: Any], + let encodedOptionValues = existingContent["EncodedOptionValues"] { + contentBlock["EncodedOptionValues"] = encodedOptionValues + } + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func patchWallpaperEntries(_ plist: inout [String: Any], desktopBlock: [String: Any], idleBlock: [String: Any]) { + var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:] + allSpaces["Desktop"] = desktopBlock + allSpaces["Idle"] = idleBlock + allSpaces["Type"] = allSpaces["Type"] ?? "individual" + plist["AllSpacesAndDisplays"] = allSpaces + + var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] + systemDefault["Desktop"] = desktopBlock + systemDefault["Idle"] = idleBlock + systemDefault["Type"] = systemDefault["Type"] ?? "individual" + plist["SystemDefault"] = systemDefault + + if var displays = plist["Displays"] as? [String: Any] { + for key in displays.keys { + if var display = displays[key] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + displays[key] = display + } + } + plist["Displays"] = displays + } + + if var spaces = plist["Spaces"] as? [String: Any] { + for spaceKey in spaces.keys { + if var space = spaces[spaceKey] as? [String: Any] { + if var defaultEntry = space["Default"] as? [String: Any] { + defaultEntry["Desktop"] = desktopBlock + defaultEntry["Idle"] = idleBlock + space["Default"] = defaultEntry + } + if var spaceDisplays = space["Displays"] as? [String: Any] { + for displayKey in spaceDisplays.keys { + if var display = spaceDisplays[displayKey] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + spaceDisplays[displayKey] = display + } + } + space["Displays"] = spaceDisplays + } + spaces[spaceKey] = space + } + } + plist["Spaces"] = spaces + } + } + + private func restartWallpaperAgent() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["WallpaperAgent"] + try? task.run() + } + + private func restoreWallpaper() { + let fm = FileManager.default + guard hasValidBackup else { return } + + do { + try fm.removeItem(at: wallpaperPlistURL) + try fm.copyItem(at: backupPlistURL, to: wallpaperPlistURL) + try fm.removeItem(at: backupPlistURL) + } catch { + print("[FullScreenArtworkWindowManager] Failed to restore plist: \(error)") + } + + restoreLiveWallpaperResources() + restartWallpaperAgent() + } + + // MARK: - Track Change Observer + + private func observeTrackChanges() { + trackChangeCancellable?.cancel() + trackChangeCancellable = MusicManager.shared.$songTitle + .combineLatest(MusicManager.shared.$artistName) + .dropFirst() + .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() + } + } + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 6e98688..6807f02 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -187,6 +187,7 @@ class LockScreenManager: ObservableObject { // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() + FullScreenArtworkWindowManager.shared.hide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index d3f723d..9588d1d 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -478,7 +478,9 @@ class MusicManager: ObservableObject { private var explicitLookupTask: Task? private var explicitLookupKey: String? - private var artworkData: Data? = nil + private(set) var artworkData: Data? = nil + + @Published var videoArtworkURL: URL? = nil private var liveStreamUnknownDurationCount: Int = 0 private var liveStreamEdgeObservationCount: Int = 0 @@ -709,6 +711,11 @@ class MusicManager: ObservableObject { || bundleChanged || contentIdentifierChanged || contentURLChanged + let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL + + if liveArtworkChanged { + self.videoArtworkURL = state.liveArtworkURL + } // Handle artwork and visual transitions for changed content let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] @@ -735,8 +742,13 @@ class MusicManager: ObservableObject { self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentURL = state.contentURL - // Fetch lyrics for new track whenever content changes self.fetchLyrics() + if let liveArtworkURL = state.liveArtworkURL { + self.videoArtworkURL = liveArtworkURL + } else { + self.fetchVideoArtwork() + } + self.refreshExplicitFlag(for: state) // Only update sneak peek if there's actual content and something changed @@ -1424,6 +1436,76 @@ class MusicManager: ObservableObject { lyricSyncTask = nil } + // MARK: - Video Artwork + + func fetchVideoArtwork() { + guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else { + videoArtworkURL = nil + return + } + guard bundleIdentifier == "com.apple.Music" else { + return + } + + let title = songTitle + let artist = artistName + + Task { + let url = await fetchAnimatedArtworkURL(title: title, artist: artist) + await MainActor.run { + self.videoArtworkURL = url + } + } + } + + private func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? { + guard !title.isEmpty, !artist.isEmpty else { return nil } + + let query = "\(title) \(artist)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=musicVideo&limit=5") + else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: searchURL) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let first = results.first, + let previewURLString = first["previewUrl"] as? String, + let previewURL = URL(string: previewURLString) + else { return nil } + + return previewURL + } catch { + return nil + } + } + + func fetchHighResArtworkURL() async -> URL? { + let title = songTitle + let artist = artistName + guard !title.isEmpty, !artist.isEmpty else { return nil } + + let query = "\(title) \(artist)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=5") + else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: searchURL) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let first = results.first, + let artworkURLString = first["artworkUrl100"] as? String + else { return nil } + + let highRes = artworkURLString.replacingOccurrences(of: "100x100", with: "3000x3000") + return URL(string: highRes) + } catch { + return nil + } + } + func toggleLyrics() { // Toggle the UI state first so the views can react immediately. showLyrics.toggle() diff --git a/DynamicIsland/managers/SpotifyAuthManager.swift b/DynamicIsland/managers/SpotifyAuthManager.swift new file mode 100644 index 0000000..f733452 --- /dev/null +++ b/DynamicIsland/managers/SpotifyAuthManager.swift @@ -0,0 +1,343 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import CryptoKit +import Defaults +import Foundation + +extension Notification.Name { + static let spotifyCanvasSessionDidChange = Notification.Name("SpotifyCanvasSessionDidChange") +} + +@MainActor +final class SpotifyAuthManager: ObservableObject { + static let shared = SpotifyAuthManager() + + @Published private(set) var isAuthenticated = false + @Published private(set) var isAuthorizing = false + @Published private(set) var authErrorMessage: String? + @Published private(set) var sessionStatusText = "Spotify cookie not configured." + + private let secretsURL = URL(string: "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/main/secrets/secretDict.json")! + private let tokenURL = URL(string: "https://open.spotify.com/api/token")! + private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + + private init() { + restorePersistedState() + } + + var configuredCookie: String { + Self.sanitizeCookie(Defaults[.spotifySPDCCookie]) + } + + var hasConfiguredCookie: Bool { + !configuredCookie.isEmpty + } + + func validateSession() async { + let sanitizedCookie = configuredCookie + Defaults[.spotifySPDCCookie] = sanitizedCookie + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + + guard !sanitizedCookie.isEmpty else { + authErrorMessage = "Paste the Spotify sp_dc cookie first." + return + } + + isAuthorizing = true + defer { + isAuthorizing = false + restorePersistedState() + } + + guard await validAccessToken(forceRefresh: true) != nil else { + if authErrorMessage == nil { + authErrorMessage = "Spotify rejected the sp_dc cookie." + } + return + } + + authErrorMessage = nil + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func clearSession() { + Defaults[.spotifySPDCCookie] = "" + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func invalidateAccessToken() { + clearCachedToken() + restorePersistedState() + } + + func validAccessToken(forceRefresh: Bool = false) async -> String? { + let sanitizedCookie = configuredCookie + if sanitizedCookie != Defaults[.spotifySPDCCookie] { + Defaults[.spotifySPDCCookie] = sanitizedCookie + } + + guard !sanitizedCookie.isEmpty else { + restorePersistedState() + return nil + } + + let cachedToken = Defaults[.spotifyAuthAccessToken] + let cachedExpiration = Defaults[.spotifyAuthAccessTokenExpiration] + if !forceRefresh, !cachedToken.isEmpty, cachedExpiration > Date().timeIntervalSince1970 + 60 { + restorePersistedState() + return cachedToken + } + + do { + let response = try await fetchWebPlayerToken(spDC: sanitizedCookie) + Defaults[.spotifyAuthAccessToken] = response.accessToken + Defaults[.spotifyAuthAccessTokenExpiration] = response.expirationDate.timeIntervalSince1970 + Defaults[.spotifyAuthLastValidatedAt] = Date().timeIntervalSince1970 + authErrorMessage = nil + restorePersistedState() + return response.accessToken + } catch { + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = error.localizedDescription + restorePersistedState() + return nil + } + } + + private func fetchWebPlayerToken(spDC: String) async throws -> SpotifyWebPlayerTokenResponse { + let secrets = try await fetchSecrets() + guard let latestSecret = latestSecretEntry(from: secrets) else { + throw SpotifyAuthError.missingSecrets + } + + let totp = try Self.generateTOTPCode(from: latestSecret.cipher) + + var components = URLComponents(url: tokenURL, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "reason", value: "transport"), + URLQueryItem(name: "productType", value: "web-player"), + URLQueryItem(name: "totp", value: totp), + URLQueryItem(name: "totpServer", value: totp), + URLQueryItem(name: "totpVer", value: latestSecret.version) + ] + + guard let url = components?.url else { + throw SpotifyAuthError.invalidRequest + } + + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("https://open.spotify.com/", forHTTPHeaderField: "Referer") + request.setValue("WebPlayer", forHTTPHeaderField: "App-Platform") + request.setValue("sp_dc=\(spDC)", forHTTPHeaderField: "Cookie") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + + let tokenResponse = try JSONDecoder().decode(SpotifyWebPlayerTokenResponse.self, from: data) + if tokenResponse.accessToken.isEmpty { + throw SpotifyAuthError.invalidTokenResponse + } + + return tokenResponse + } + + private func fetchSecrets() async throws -> [String: [Int]] { + var request = URLRequest(url: secretsURL) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + return try JSONDecoder().decode([String: [Int]].self, from: data) + } + + private func latestSecretEntry(from secrets: [String: [Int]]) -> (version: String, cipher: [Int])? { + var latestVersion: String? + var latestCipher: [Int]? + var highestNumericVersion = Int.min + + for (version, cipher) in secrets { + guard let numericVersion = Int(version) else { continue } + if numericVersion > highestNumericVersion { + highestNumericVersion = numericVersion + latestVersion = version + latestCipher = cipher + } + } + + guard let latestVersion, let latestCipher else { + return nil + } + + return (latestVersion, latestCipher) + } + + private func clearCachedToken() { + Defaults[.spotifyAuthAccessToken] = "" + Defaults[.spotifyAuthAccessTokenExpiration] = 0 + } + + private func restorePersistedState() { + let cookie = configuredCookie + let hasToken = !Defaults[.spotifyAuthAccessToken].isEmpty + && Defaults[.spotifyAuthAccessTokenExpiration] > Date().timeIntervalSince1970 + 60 + let hasValidatedCookie = Defaults[.spotifyAuthLastValidatedAt] > 0 + + isAuthenticated = !cookie.isEmpty && (hasToken || hasValidatedCookie) + + if cookie.isEmpty { + sessionStatusText = "Spotify cookie not configured." + } else if hasToken { + sessionStatusText = "Spotify Canvas session ready." + } else if hasValidatedCookie { + sessionStatusText = "Cookie saved. The access token will refresh automatically." + } else { + sessionStatusText = "Cookie pasted. Validate it once to enable Canvas lookup." + } + } + + static func sanitizeCookie(_ rawValue: String) -> String { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + + if let range = value.range(of: "sp_dc=") { + value = String(value[range.upperBound...]) + } + + if let firstSemicolon = value.firstIndex(of: ";") { + value = String(value[.. String { + let transformed = cipher.enumerated().map { index, value in + value ^ ((index % 33) + 9) + } + + let secretString = transformed.map(String.init).joined() + guard let secretData = secretString.data(using: .utf8) else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let counter = UInt64(Date().timeIntervalSince1970 / 30) + var counterBytes = counter.bigEndian + let counterData = Data(bytes: &counterBytes, count: MemoryLayout.size) + + let mac = HMAC.authenticationCode( + for: counterData, + using: SymmetricKey(data: secretData) + ) + let hashData = Data(mac) + let offset = Int(hashData.last.map { $0 & 0x0F } ?? 0) + let truncatedRange = offset..<(offset + 4) + + guard truncatedRange.upperBound <= hashData.count else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let truncatedBytes = Array(hashData[truncatedRange]) + let truncated = + ((UInt32(truncatedBytes[0]) << 24) + | (UInt32(truncatedBytes[1]) << 16) + | (UInt32(truncatedBytes[2]) << 8) + | UInt32(truncatedBytes[3])) + & 0x7FFF_FFFF + + return String(format: "%06u", truncated % 1_000_000) + } + + private static func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyAuthError.invalidServerResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = object["error"] as? String { + throw SpotifyAuthError.serverError(message) + } + + if let body = String(data: data, encoding: .utf8), !body.isEmpty { + throw SpotifyAuthError.serverError(body) + } + + throw SpotifyAuthError.httpFailure(httpResponse.statusCode) + } + } +} + +private struct SpotifyWebPlayerTokenResponse: Decodable { + let accessToken: String + let accessTokenExpirationTimestampMs: Double? + + var expirationDate: Date { + if let accessTokenExpirationTimestampMs { + return Date(timeIntervalSince1970: accessTokenExpirationTimestampMs / 1000) + } + return Date().addingTimeInterval(3300) + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case accessTokenExpirationTimestampMs + } +} + +private enum SpotifyAuthError: LocalizedError { + case invalidRequest + case invalidServerResponse + case invalidTokenResponse + case invalidSecretMaterial + case missingSecrets + case httpFailure(Int) + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidRequest: + return "Unable to build the Spotify token request." + case .invalidServerResponse: + return "Spotify returned an invalid response." + case .invalidTokenResponse: + return "Spotify did not return a usable access token." + case .invalidSecretMaterial: + return "The Spotify TOTP secret could not be decoded." + case .missingSecrets: + return "No valid Spotify TOTP secret was found." + case let .httpFailure(statusCode): + return "Spotify token request failed with HTTP \(statusCode)." + case let .serverError(message): + return message + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 198cd85..fd04466 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -910,6 +910,8 @@ extension Defaults.Keys { ) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) + static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) + static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") @@ -988,6 +990,10 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + static let spotifySPDCCookie = Key("spotifySPDCCookie", default: "") + static let spotifyAuthAccessToken = Key("spotifyAuthAccessToken", default: "") + static let spotifyAuthAccessTokenExpiration = Key("spotifyAuthAccessTokenExpiration", default: 0) + static let spotifyAuthLastValidatedAt = Key("spotifyAuthLastValidatedAt", default: 0) // MARK: Bluetooth Audio Devices static let showBluetoothDeviceConnections = Key("showBluetoothDeviceConnections", default: true) diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index 516e6c2..312a708 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -44,6 +44,7 @@ struct PlaybackState { var repeatMode: RepeatMode = .off var lastUpdated: Date = Date.distantPast var artwork: Data? + var liveArtworkURL: URL? } extension PlaybackState: Equatable { @@ -61,5 +62,6 @@ extension PlaybackState: Equatable { && lhs.isShuffled == rhs.isShuffled && lhs.repeatMode == rhs.repeatMode && lhs.artwork == rhs.artwork + && lhs.liveArtworkURL == rhs.liveArtworkURL } } From 3afa96823610f3829faa8b0b1d469532c9e4ebc7 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 16 Apr 2026 16:23:10 +0530 Subject: [PATCH 03/18] Atoll: Add MusicKit animated artwork fetching for Apple Music Uses MusicKit MusicCatalogSearchRequest to find the current track, then fetches editorialVideo.motionTallVideo3x4 HLS URL via MusicDataRequest. The animated artwork URL is pre-fetched on track change and cached per song for instant availability. Adds NSAppleMusicUsageDescription to Info.plist for MusicKit authorization. Signed-off-by: Jis G Jacob --- DynamicIsland/Info.plist | 2 + .../managers/AnimatedArtworkManager.swift | 149 ++++++++++++++++++ DynamicIsland/managers/MusicManager.swift | 52 +----- 3 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 DynamicIsland/managers/AnimatedArtworkManager.swift diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index 92fe265..181d8a5 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -7,6 +7,8 @@ NSAllowsArbitraryLoads + NSAppleMusicUsageDescription + Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper. NSAudioCaptureUsageDescription Atoll needs audio capture permission to display the real-time music waveform visualizer. NSScreenCaptureUsageDescription diff --git a/DynamicIsland/managers/AnimatedArtworkManager.swift b/DynamicIsland/managers/AnimatedArtworkManager.swift new file mode 100644 index 0000000..2993e63 --- /dev/null +++ b/DynamicIsland/managers/AnimatedArtworkManager.swift @@ -0,0 +1,149 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import MusicKit + +actor AnimatedArtworkManager { + static let shared = AnimatedArtworkManager() + + private var cachedSongID: String? + private var cachedVideoURL: URL? + private var cachedKey: String? + + func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? { + let key = "\(title)|\(artist)" + if key == cachedKey, let url = cachedVideoURL { + return url + } + + guard await requestMusicAuthorization() else { return nil } + + guard let songID = await searchSongID(title: title, artist: artist) else { + return nil + } + + guard let videoURL = await fetchEditorialVideoURL(songID: songID) else { + return nil + } + + cachedKey = key + cachedSongID = songID + cachedVideoURL = videoURL + return videoURL + } + + func clearCache() { + cachedKey = nil + cachedSongID = nil + cachedVideoURL = nil + } + + // MARK: - MusicKit Authorization + + private func requestMusicAuthorization() async -> Bool { + let status = MusicAuthorization.currentStatus + if status == .authorized { return true } + + let newStatus = await MusicAuthorization.request() + return newStatus == .authorized + } + + // MARK: - Song Search + + private func searchSongID(title: String, artist: String) async -> String? { + let term = "\(title) \(artist)" + var request = MusicCatalogSearchRequest(term: term, types: [Song.self]) + request.limit = 5 + + do { + let response = try await request.response() + let normalizedTitle = title.lowercased() + let normalizedArtist = artist.lowercased() + + let match = response.songs.first(where: { + $0.title.lowercased() == normalizedTitle && + $0.artistName.lowercased() == normalizedArtist + }) ?? response.songs.first(where: { + $0.title.lowercased() == normalizedTitle + }) ?? response.songs.first + + guard let song = match else { return nil } + return song.id.rawValue + } catch { + print("[AnimatedArtworkManager] Search failed: \(error)") + return nil + } + } + + // MARK: - Editorial Video Fetch + + private func fetchEditorialVideoURL(songID: String) async -> URL? { + let storefront: String + if let code = try? await MusicDataRequest.currentCountryCode { + storefront = code + } else { + storefront = "us" + } + + return await fetchEditorialVideoURLFallback(songID: songID, storefront: storefront) + } + + private func fetchEditorialVideoURLFallback(songID: String, storefront: String) async -> URL? { + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/\(storefront)/songs/\(songID)?extend=editorialVideo") else { + return nil + } + + do { + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + let response = try await request.response() + + guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any], + let data = json["data"] as? [[String: Any]], + let first = data.first, + let attributes = first["attributes"] as? [String: Any], + let editorialVideo = attributes["editorialVideo"] as? [String: Any] + else { return nil } + + if let motionTall = editorialVideo["motionTallVideo3x4"] as? [String: Any], + let videoURLString = motionTall["video"] as? String, + let videoURL = URL(string: videoURLString) { + return videoURL + } + + if let motionSquare = editorialVideo["motionSquareVideo1x1"] as? [String: Any], + let videoURLString = motionSquare["video"] as? String, + let videoURL = URL(string: videoURLString) { + return videoURL + } + + let hlsPattern = try? NSRegularExpression(pattern: "https://mvod\\.itunes\\.apple\\.com[^\"]+\\.m3u8") + let jsonString = String(data: response.data, encoding: .utf8) ?? "" + let range = NSRange(jsonString.startIndex..., in: jsonString) + if let match = hlsPattern?.firstMatch(in: jsonString, range: range), + let matchRange = Range(match.range, in: jsonString) { + return URL(string: String(jsonString[matchRange])) + } + + return nil + } catch { + print("[AnimatedArtworkManager] Editorial video fetch failed: \(error)") + return nil + } + } +} diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 9588d1d..8fed3f2 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -1451,61 +1451,15 @@ class MusicManager: ObservableObject { let artist = artistName Task { - let url = await fetchAnimatedArtworkURL(title: title, artist: artist) + let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL( + title: title, artist: artist + ) await MainActor.run { self.videoArtworkURL = url } } } - private func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? { - guard !title.isEmpty, !artist.isEmpty else { return nil } - - let query = "\(title) \(artist)" - guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=musicVideo&limit=5") - else { return nil } - - do { - let (data, _) = try await URLSession.shared.data(from: searchURL) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let results = json["results"] as? [[String: Any]], - let first = results.first, - let previewURLString = first["previewUrl"] as? String, - let previewURL = URL(string: previewURLString) - else { return nil } - - return previewURL - } catch { - return nil - } - } - - func fetchHighResArtworkURL() async -> URL? { - let title = songTitle - let artist = artistName - guard !title.isEmpty, !artist.isEmpty else { return nil } - - let query = "\(title) \(artist)" - guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=5") - else { return nil } - - do { - let (data, _) = try await URLSession.shared.data(from: searchURL) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let results = json["results"] as? [[String: Any]], - let first = results.first, - let artworkURLString = first["artworkUrl100"] as? String - else { return nil } - - let highRes = artworkURLString.replacingOccurrences(of: "100x100", with: "3000x3000") - return URL(string: highRes) - } catch { - return nil - } - } - func toggleLyrics() { // Toggle the UI state first so the views can react immediately. showLyrics.toggle() From fd58ae7f89850e1d891eeab24b35d5945641c04b Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 16 Apr 2026 16:32:16 +0530 Subject: [PATCH 04/18] Atoll: Remove timer name from lock screen timer widget The widget is too small to display both the title and countdown comfortably. Replaced the title section with a spacer so the controls and countdown spread out cleanly. Signed-off-by: Jis G Jacob --- .../components/LockScreen/LockScreenTimerWidget.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 362c853..d3524b1 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -195,8 +195,7 @@ struct LockScreenTimerWidget: View { controlButtons .padding(.trailing, 12) - titleSection - .frame(maxWidth: .infinity) + Spacer() countdownSection .padding(.leading, 12) From 531340ae929400c3cad89639b72324dcd1ae78d6 Mon Sep 17 00:00:00 2001 From: delli Date: Wed, 22 Apr 2026 23:02:31 +0200 Subject: [PATCH 05/18] pleasseeeee --- DynamicIsland/MediaControllers/SpotifyController.swift | 3 --- DynamicIsland/managers/MusicManager.swift | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 83d09e1..e1e56db 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -208,8 +208,6 @@ class SpotifyController: MediaControllerProtocol { } } - // MARK: - Private Methods - private func scheduleCanvasFetchIfNeeded(for trackURI: String) { guard !trackURI.isEmpty else { return } guard playbackState.liveArtworkURL == nil else { return } @@ -243,7 +241,6 @@ class SpotifyController: MediaControllerProtocol { } } } - private func executeCommand(_ command: String) async { let script = "tell application \"Spotify\" to \(command)" try? await AppleScriptHelper.executeVoid(script) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 8fed3f2..437288c 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -703,6 +703,7 @@ class MusicManager: ObservableObject { // Check for artwork changes let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + let hasContentChange = titleChanged || artistChanged @@ -751,6 +752,7 @@ class MusicManager: ObservableObject { self.refreshExplicitFlag(for: state) + // Only update sneak peek if there's actual content and something changed if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { self.updateSneakPeek() @@ -1443,6 +1445,8 @@ class MusicManager: ObservableObject { videoArtworkURL = nil return } + // Se il player non è Apple Music, non toccare videoArtworkURL: + // SpotifyController gestisce il canvas in modo autonomo tramite liveArtworkURL. guard bundleIdentifier == "com.apple.Music" else { return } From f04de8f54a840fe39eaafde1241e68187de91e3f Mon Sep 17 00:00:00 2001 From: delli Date: Wed, 22 Apr 2026 23:09:41 +0200 Subject: [PATCH 06/18] pleasseeee --- DynamicIsland/managers/MusicManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 437288c..1047474 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -1558,7 +1558,6 @@ private struct AlbumArtFlipModifier: ViewModifier { // Use a small tolerance to avoid flickering exactly at 90°/270°. if cos > 0.001 { return 1 } if cos < -0.001 { return -1 } - // At the exact edge, prefer the side we're animating toward. return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1 } } From cc09f1707452b3fd858a46d06bc6954dbc0ff42a Mon Sep 17 00:00:00 2001 From: delli Date: Thu, 23 Apr 2026 15:46:49 +0200 Subject: [PATCH 07/18] final version?? --- DynamicIsland/Localizable.xcstrings | 9 +++++ .../LockScreen/LockScreenMusicPanel.swift | 10 ++++-- .../LockScreen/LockScreenTimerWidget.swift | 27 --------------- .../components/Settings/SettingsView.swift | 34 +++++++++++++++++++ DynamicIsland/models/Constants.swift | 1 + 5 files changed, 51 insertions(+), 30 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2273b82..cce2b6e 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -19814,6 +19814,9 @@ } } } + }, + "Fullscreen artwork on right-click" : { + }, "Fully charged" : { "comment" : "Charging fallback label when already charged", @@ -22845,6 +22848,9 @@ } } } + }, + "Keep album art visible during fullscreen artwork" : { + }, "Keep terminal open until clicked outside" : { "localizations" : { @@ -34260,6 +34266,9 @@ } } } + }, + "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." : { + }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 4d6fbfd..3edc5f1 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -68,6 +68,7 @@ struct LockScreenMusicPanel: View { @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth @Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled + @Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork @Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork init(animator: LockScreenPanelAnimator) { @@ -338,7 +339,7 @@ struct LockScreenMusicPanel: View { private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { ZStack(alignment: .bottomTrailing) { - if isArtworkFullscreen { + if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Color.white.opacity(0.06)) .overlay( @@ -359,8 +360,11 @@ struct LockScreenMusicPanel: View { } } } - .albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle) - .parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, suspended: isParallaxSuspended) + .albumArtFlip(angle: isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle) + .parallax3D( + enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork), + 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)) diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index d3524b1..0d6ce7c 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -45,10 +45,6 @@ struct LockScreenTimerWidget: View { } } - private func displayFont(size: CGFloat) -> Font { - .system(size: size, weight: .semibold, design: .rounded) - } - private var hasHoursComponent: Bool { abs(timerManager.remainingTime) >= 3600 } @@ -57,12 +53,6 @@ struct LockScreenTimerWidget: View { abs(timerManager.remainingTime) >= 36_000 // 10 hours or more } - private var titleFrameWidth: CGFloat { - if hasDoubleDigitHours { return 60 } - if hasHoursComponent { return 80 } - return 110 - } - private var countdownFrameWidth: CGFloat { if hasDoubleDigitHours { return 200 } if hasHoursComponent { return 185 } @@ -74,10 +64,6 @@ struct LockScreenTimerWidget: View { return .system(size: baseSize, weight: .bold, design: .rounded) } - private var timerLabel: String { - timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName - } - private var countdownText: String { timerManager.formattedRemainingTime() } @@ -260,19 +246,6 @@ struct LockScreenTimerWidget: View { .layoutPriority(2) } - private var titleSection: some View { - MarqueeText( - .constant(timerLabel), - font: displayFont(size: 15), - nsFont: .title3, - textColor: accentColor, - minDuration: 0.16, - frameWidth: titleFrameWidth - ) - .frame(maxWidth: .infinity, alignment: .center) - .layoutPriority(0) - } - private var accentRibbon: some View { Capsule() .fill(accentGradient) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index b44c3d2..e3cc1ee 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2450,6 +2450,7 @@ struct Media: View { @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled @Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer @Default(.parallaxEffectIntensity) private var parallaxEffectIntensity @@ -2696,6 +2697,22 @@ struct Media: View { .opacity(enableLockScreenMediaWidget ? 1 : 0.5) .settingsHighlight(id: highlightID("Enable media panel blur")) } + VStack(alignment: .leading, spacing: 4) { + Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) { + Text("Fullscreen artwork on right-click") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + 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.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } header: { Text("Lock Screen Integration") } footer: { @@ -4576,6 +4593,7 @@ struct LockScreenSettings: View { @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget @@ -4809,6 +4827,22 @@ struct LockScreenSettings: View { .opacity(enableLockScreenMediaWidget ? 1 : 0.5) .settingsHighlight(id: highlightID("Enable media panel blur")) } + VStack(alignment: .leading, spacing: 4) { + Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) { + Text("Fullscreen artwork on right-click") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) + 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.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } if !showStandardMediaControls { Text("Enable Dynamic Island media controls to manage the lock screen panel.") diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index fd04466..4390df2 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -911,6 +911,7 @@ extension Defaults.Keys { static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) + static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false) static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) From c8a6c2b1faac72289c7d060bda90d01ffe312eeb Mon Sep 17 00:00:00 2001 From: delli Date: Fri, 8 May 2026 19:50:26 +0200 Subject: [PATCH 08/18] new version --- DynamicIsland/Localizable.xcstrings | 11 +- .../MediaControllers/SpotifyController.swift | 10 +- .../LockScreen/LockScreenMusicPanel.swift | 154 ++- .../Notch/MinimalisticMusicPlayerView.swift | 41 +- .../components/Notch/NotchHomeView.swift | 165 ++- .../components/Settings/SettingsView.swift | 20 +- .../FullScreenArtworkWindowManager.swift | 970 ++++++++++++++++-- .../managers/LockScreenPanelManager.swift | 111 +- DynamicIsland/managers/MusicManager.swift | 238 ++++- DynamicIsland/models/Constants.swift | 2 + 10 files changed, 1533 insertions(+), 189 deletions(-) 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) From d1d9d75ba8e9fb05605ef28143763271641ee590 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 16:08:25 +0530 Subject: [PATCH 09/18] Atoll: drop lockscreen music player below widgets in canvas fallback The Spotify Canvas fallback layout previously centered the panel+artwork group on screenFrame.midY, so the enlarged artwork tile could push up into the row of circular widgets (battery, audio, AQI, weather). Clamp maxCenterY against the bottom edge of both LockScreenWeatherPanelManager.latestFrame and LockScreenTimerWidgetPanelManager.latestFrame, with a 20pt clearance, and guard against the clamp inverting via max(maxCenterY, minCenterY). The panel reflows through the existing .atollLockScreenPanelFrameDidChange notification, so the artwork overlay window follows the new panel position automatically. Signed-off-by: Jis G Jacob --- .../managers/LockScreenPanelManager.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 5fab24c..e076fe7 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -329,8 +329,22 @@ class LockScreenPanelManager { 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) + var maxCenterY = screenFrame.maxY - largestHalfHeight - 84 + + let widgetClearance: CGFloat = 20 + if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame, + !weatherFrame.isEmpty { + let weatherTopConstrainedCenterY = weatherFrame.minY - widgetClearance - largestHalfHeight + maxCenterY = min(maxCenterY, weatherTopConstrainedCenterY) + } + if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame, + !timerFrame.isEmpty { + let timerTopConstrainedCenterY = timerFrame.minY - widgetClearance - largestHalfHeight + maxCenterY = min(maxCenterY, timerTopConstrainedCenterY) + } + + let resolvedMaxCenterY = max(maxCenterY, minCenterY) + let centerY = min(max(baseCenterY, minCenterY), resolvedMaxCenterY) return NSRect( x: groupOriginX + artworkSide + spacing, From a8d7dff322b860dcffa24f4b49ae13c27c9ecb95 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 16:10:17 +0530 Subject: [PATCH 10/18] Atoll: deliver Spotify Canvas as lockscreen wallpaper without album-art flash Two fixes for the right-click-artwork-to-wallpaper flow on Spotify tracks that have a Canvas video. 1. Force AllSpacesAndDisplays.Type and SystemDefault.Type to "individual" while the artwork is active (was: keep existing value via nil-coalescing). On macOS Tahoe the user-level wallpaper rotation honors the parent Type field, so leaving it as "folder" caused WallpaperAgent to advance to the next image in a rotating wallpaper pack instead of honoring the patched single-image / aerial choice. The original Type is preserved in the plist backup at temp/atoll_wallpaper_backup.plist and is restored byte-for-byte on dismiss, so rotation resumes normally after the artwork is dismissed. The forced override is also applied to nested Displays / Spaces blocks but only when Type was already present, to avoid mutating block shapes that intentionally omit it. 2. When the Canvas is expected at right-click time (effectiveVideoURL != nil), defer applyArtworkToPlist and restartWallpaperAgent entirely. Previously the flow applied raw album art as the static wallpaper, restarted the agent, then prepared and applied the aerial in a second restart, causing a visible flash of the raw album cover. The deferred path stores the blurred fallback URL in pendingFallbackStaticURL and only touches the plist once: with the aerial on success, or with the blurred album cover via applyDeferredStaticFallback if the aerial asset prep or aerial-plist patch fails. The in-app videoWindow continues to mask the desktop wallpaper during prep on the unlocked screen; the lock screen simply transitions from the existing wallpaper straight to the Canvas video. The earlier ?? nil-coalescing in patchWallpaperEntries also covered per-Display, per-Space.Default, and per-Space.Displays Type fields, so those are forced too where present. Signed-off-by: Jis G Jacob --- .../FullScreenArtworkWindowManager.swift | 85 +++++++++++++++---- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift index b58b3c8..f790e8b 100644 --- a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -324,6 +324,8 @@ final class FullScreenArtworkWindowManager: ObservableObject { private var isLiveWallpaperAllowed = false private var activeLiveWallpaperFingerprint: String? private var activeWallpaperKey: String? + private var pendingFallbackStaticURL: URL? + private var pendingFallbackWallpaperKey: String? private var fallbackRightClickMonitor: Any? private var artworkLayoutOverCanvasPreferenceCancellable: AnyCancellable? private var lyricsTextCancellable: AnyCancellable? @@ -358,6 +360,8 @@ final class FullScreenArtworkWindowManager: ObservableObject { isLiveWallpaperAllowed = false activeLiveWallpaperFingerprint = nil activeWallpaperKey = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil let shouldRestoreStandardPanelLayout = isShowingSpotifyCanvasFallback isShowingSpotifyCanvasFallback = false removeFallbackRightClickMonitor() @@ -439,12 +443,14 @@ final class FullScreenArtworkWindowManager: ObservableObject { let previousFallbackState = isShowingSpotifyCanvasFallback guard let rawArtworkFileURL = persistedArtworkFileURL(for: artwork, fingerprint: artworkFingerprint) else { return } - let wallpaperURL = shouldUseStaticFallbackWallpaper - ? blurredWallpaperFileURL(for: artwork, fingerprint: artworkFingerprint, screen: screen) ?? rawArtworkFileURL + let expectCanvas = effectiveVideoURL != nil + let blurredFallbackURL = blurredWallpaperFileURL(for: artwork, fingerprint: artworkFingerprint, screen: screen) ?? rawArtworkFileURL + let wallpaperURL = (shouldUseStaticFallbackWallpaper || expectCanvas) + ? blurredFallbackURL : rawArtworkFileURL let wallpaperKey = wallpaperIdentityKey( fingerprint: artworkFingerprint, - usesFallback: shouldUseStaticFallbackWallpaper, + usesFallback: shouldUseStaticFallbackWallpaper || expectCanvas, screen: screen ) let desiredLiveWallpaperFingerprint = effectiveVideoURL?.absoluteString @@ -459,17 +465,26 @@ final class FullScreenArtworkWindowManager: ObservableObject { } if requiresWallpaperRefresh { - showWallpaperTransition(on: screen, imageURL: wallpaperURL) + if expectCanvas { + pendingFallbackStaticURL = blurredFallbackURL + pendingFallbackWallpaperKey = wallpaperKey + activeWallpaperKey = nil + activeLiveWallpaperFingerprint = nil + } else { + showWallpaperTransition(on: screen, imageURL: wallpaperURL) - guard applyArtworkToPlist(imageURL: wallpaperURL) else { - print("[FullScreenArtworkWindowManager] Failed to patch plist") - hideWallpaperTransition() - return + guard applyArtworkToPlist(imageURL: wallpaperURL) else { + print("[FullScreenArtworkWindowManager] Failed to patch plist") + hideWallpaperTransition() + return + } + + restartWallpaperAgent() + activeWallpaperKey = wallpaperKey + activeLiveWallpaperFingerprint = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil } - - restartWallpaperAgent() - activeWallpaperKey = wallpaperKey - activeLiveWallpaperFingerprint = nil } isShowing = true @@ -773,6 +788,27 @@ final class FullScreenArtworkWindowManager: ObservableObject { return finalBitmap.representation(using: .png, properties: [:]) } + private func applyDeferredStaticFallback() { + guard let url = pendingFallbackStaticURL else { return } + let key = pendingFallbackWallpaperKey + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + + if let screen = NSScreen.main { + showWallpaperTransition(on: screen, imageURL: url) + } + guard applyArtworkToPlist(imageURL: url) else { + print("[FullScreenArtworkWindowManager] Failed to apply deferred static fallback") + hideWallpaperTransition() + return + } + restartWallpaperAgent() + activeWallpaperKey = key + activeLiveWallpaperFingerprint = nil + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) { liveWallpaperTask?.cancel() liveWallpaperTask = nil @@ -837,10 +873,18 @@ final class FullScreenArtworkWindowManager: ObservableObject { 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 } + guard prepared else { + self.applyDeferredStaticFallback() + return + } + guard self.applyAerialToPlist(assetID: assetID) else { + self.applyDeferredStaticFallback() + return + } self.activeLiveWallpaperFingerprint = nextFingerprint + self.pendingFallbackStaticURL = nil + self.pendingFallbackWallpaperKey = nil self.restartWallpaperAgent() self.scheduleHideVideoWindow(after: .milliseconds(950), expectedURL: videoURL) print("[FullScreenArtworkWindowManager] Live wallpaper applied") @@ -1549,13 +1593,13 @@ final class FullScreenArtworkWindowManager: ObservableObject { var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:] allSpaces["Desktop"] = desktopBlock allSpaces["Idle"] = idleBlock - allSpaces["Type"] = allSpaces["Type"] ?? "individual" + allSpaces["Type"] = "individual" plist["AllSpacesAndDisplays"] = allSpaces var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] systemDefault["Desktop"] = desktopBlock systemDefault["Idle"] = idleBlock - systemDefault["Type"] = systemDefault["Type"] ?? "individual" + systemDefault["Type"] = "individual" plist["SystemDefault"] = systemDefault if var displays = plist["Displays"] as? [String: Any] { @@ -1563,6 +1607,9 @@ final class FullScreenArtworkWindowManager: ObservableObject { if var display = displays[key] as? [String: Any] { display["Desktop"] = desktopBlock display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } displays[key] = display } } @@ -1575,6 +1622,9 @@ final class FullScreenArtworkWindowManager: ObservableObject { if var defaultEntry = space["Default"] as? [String: Any] { defaultEntry["Desktop"] = desktopBlock defaultEntry["Idle"] = idleBlock + if defaultEntry["Type"] != nil { + defaultEntry["Type"] = "individual" + } space["Default"] = defaultEntry } if var spaceDisplays = space["Displays"] as? [String: Any] { @@ -1582,6 +1632,9 @@ final class FullScreenArtworkWindowManager: ObservableObject { if var display = spaceDisplays[displayKey] as? [String: Any] { display["Desktop"] = desktopBlock display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } spaceDisplays[displayKey] = display } } From 43f1fb4283319d9222ae0eee8ef4055b6d7ecc17 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 16:10:50 +0530 Subject: [PATCH 11/18] Atoll: raise MemoryUsageMonitor threshold to 4 GiB in DEBUG builds The 1 GiB resident-size threshold is reasonable for a release build, but Swift/SwiftUI debug builds plus the AVPlayer-backed Canvas video pipeline and SkyLight overlay windows can easily exceed it during normal Xcode-attached development. The restart-alert dialog firing mid-development is disruptive and not actionable. Keep the 1 GiB safety net on release, raise to 4 GiB in DEBUG so developers running under Xcode aren't interrupted by spurious restart prompts. The poll interval, cooldown, and alert text all stay the same. Signed-off-by: Jis G Jacob --- DynamicIsland/managers/MemoryUsageMonitor.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DynamicIsland/managers/MemoryUsageMonitor.swift b/DynamicIsland/managers/MemoryUsageMonitor.swift index 34c9898..d03b785 100644 --- a/DynamicIsland/managers/MemoryUsageMonitor.swift +++ b/DynamicIsland/managers/MemoryUsageMonitor.swift @@ -24,7 +24,11 @@ import Darwin final class MemoryUsageMonitor { static let shared = MemoryUsageMonitor() +#if DEBUG + private let thresholdBytes: UInt64 = 4 * 1_024 * 1_024 * 1_024 +#else private let thresholdBytes: UInt64 = 1_024 * 1_024 * 1_024 +#endif private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact private let restartCooldown: TimeInterval = 300 private let logSampleInterval: TimeInterval = 300 From d205923b02a3040180cd7db4e36a75c638447af0 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 16:22:24 +0530 Subject: [PATCH 12/18] Atoll: distinguish charging-on-hold from fully charged on lockscreen widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lockscreen battery widget treated any plugged-in-but-not-charging state as 'Fully charged'. macOS pauses charging for battery health (e.g., optimized battery charging at 80%), so this misreported the actual battery state. Now 'Fully charged' shows only at 100% + plugged in; otherwise the detail label reads '% • On hold' (with a matching accessibility string). Charging-in-progress and fully-charged states are unchanged. --- DynamicIsland/Localizable.xcstrings | 31 ++++++++++++++++--- .../LockScreen/LockScreenWeatherWidget.swift | 22 +++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 1e64f49..9c4d703 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,6 +2314,7 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3149,6 +3150,7 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3706,6 +3708,7 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5632,10 +5635,6 @@ } } }, - "Amazon Music" : { - "comment" : "Spotify music service.", - "isCommentAutoGenerated" : true - }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6029,6 +6028,7 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6936,6 +6936,7 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -7087,6 +7088,9 @@ } } }, + "Battery at %d percent, charging on hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, "Battery charging" : { "comment" : "Battery charging without estimate", "localizations" : { @@ -9048,6 +9052,9 @@ } } }, + "Charging on hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, "Charging on Hold: Desktop Mode" : { "localizations" : { "ar" : { @@ -14535,6 +14542,7 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20845,6 +20853,7 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -29970,6 +29979,7 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30157,6 +30167,9 @@ } } }, + "On hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, "One or more files failed to load" : { "localizations" : { "ar" : { @@ -30719,6 +30732,7 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34275,6 +34289,7 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35796,6 +35811,7 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38231,6 +38247,7 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38255,6 +38272,7 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38482,6 +38500,7 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39209,6 +39228,7 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39449,6 +39469,7 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42276,6 +42297,7 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45651,6 +45673,7 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 5a31718..e37bf27 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -1261,7 +1261,15 @@ struct LockScreenWeatherWidget: View { private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String { if info.isPluggedIn && !info.isCharging { - return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged") + let level = info.batteryLevel.map(clampedBatteryLevel) + if level == 100 { + return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged") + } + if let level { + let onHold = NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system") + return "\(level)% • \(onHold)" + } + return NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system") } return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available") } @@ -1456,7 +1464,17 @@ struct LockScreenWeatherWidget: View { } if charging.isPluggedIn && !charging.isCharging { - return NSLocalizedString("Battery fully charged", comment: "Battery is full") + let level = charging.batteryLevel.map(clampedBatteryLevel) + if level == 100 { + return NSLocalizedString("Battery fully charged", comment: "Battery is full") + } + if let level { + return String( + format: NSLocalizedString("Battery at %d percent, charging on hold", comment: "Plugged in but charging is paused by the system"), + level + ) + } + return NSLocalizedString("Charging on hold", comment: "Plugged in but charging is paused by the system") } if snapshot.showsChargingPercentage, let level = charging.batteryLevel { From b6591bca18019821ca67743b2544eea15ef0ef6a Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 22:51:05 +0530 Subject: [PATCH 13/18] Atoll: keep lockscreen player legible over Spotify Canvas wallpaper Replace the transparent canvas-fallback panel background with a soft ultra-thin dark scrim plus a gentle vertical gradient and hairline stroke, and add a subtle drop shadow on the foreground so titles, time labels, and controls stay readable on bright or busy canvas videos. Restore consistent horizontal and vertical padding in canvas-fallback mode so the marquee title and inline elapsed/remaining timers no longer hug the panel edges. Signed-off-by: Jis G Jacob --- .../LockScreen/LockScreenMusicPanel.swift | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index f5830fa..f9a86dc 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -153,6 +153,12 @@ struct LockScreenMusicPanel: View { ZStack(alignment: .topLeading) { panelBackgroundLayer panelForeground + .shadow( + color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.55) : .clear, + radius: usesSpotifyCanvasFallbackContentPresentation ? 10 : 0, + x: 0, + y: usesSpotifyCanvasFallbackContentPresentation ? 2 : 0 + ) } .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) @@ -162,10 +168,10 @@ struct LockScreenMusicPanel: View { } } .shadow( - color: usesSpotifyCanvasFallbackContentPresentation ? .clear : Color.black.opacity(0.3), - radius: usesSpotifyCanvasFallbackContentPresentation ? 0 : 20, + color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.35) : Color.black.opacity(0.3), + radius: usesSpotifyCanvasFallbackContentPresentation ? 26 : 20, x: 0, - y: usesSpotifyCanvasFallbackContentPresentation ? 0 : 10 + y: usesSpotifyCanvasFallbackContentPresentation ? 14 : 10 ) .contentShape(Rectangle()) .animation(.easeInOut(duration: 0.28), value: isExpanded) @@ -253,8 +259,8 @@ struct LockScreenMusicPanel: View { collapsedLayout } } - .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? 8 : (isExpanded ? 24 : 20)) - .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? 0 : (isExpanded ? 22 : 16)) + .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20)) + .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16)) .frame( maxWidth: .infinity, maxHeight: .infinity, @@ -1138,9 +1144,7 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackgroundLayer: some View { if usesSpotifyCanvasFallbackContentPresentation { - Color.clear - .allowsHitTesting(false) - .accessibilityHidden(true) + canvasFallbackScrim } else if usesCustomLiquidGlass { customLiquidPanelBackdrop } else if usesStandardLiquidGlass { @@ -1152,6 +1156,31 @@ struct LockScreenMusicPanel: View { } } + private var canvasFallbackScrim: some View { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + .overlay { + LinearGradient( + colors: [ + Color.black.opacity(0.32), + Color.black.opacity(0.18), + Color.black.opacity(0.32) + ], + startPoint: .top, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) + } + .overlay { + RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 0.6) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + @ViewBuilder private var standardLiquidPanelBackdrop: some View { if #available(macOS 26.0, *) { From 8bea68d0fcab22b004eaede52341c19553ef5ab2 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 22:52:25 +0530 Subject: [PATCH 14/18] Atoll: re-SIGSTOP OSDUIHelper across launchd respawns OSDUIHelper jetsam-exits when idle and launchd spawns a fresh PID on the next media-key press, so the one-shot SIGSTOP only suppressed the first incarnation. SystemOSDManager now polls for the PID after kickstart and runs a background watcher that re-SIGSTOPs every new incarnation, with state guarded by OSAllocatedUnfairLock. Watcher is torn down on enableSystemHUD(). Fixes the legacy OSDUIHelper duplicate-HUD path in #419. The macOS 26 Liquid Glass HUD (loginwindow -> BezelServices -> ControlCenter SystemBannerService) is a separate render path gated by the com.apple.private.system-banner-client entitlement and is not suppressible from a regular user-space app; it needs a privileged SMAppService helper and is out of scope here. Signed-off-by: Jis G Jacob --- DynamicIsland/managers/SystemOSDManager.swift | 141 ++++++++++++++++-- 1 file changed, 130 insertions(+), 11 deletions(-) diff --git a/DynamicIsland/managers/SystemOSDManager.swift b/DynamicIsland/managers/SystemOSDManager.swift index 52e8fc8..9281fda 100644 --- a/DynamicIsland/managers/SystemOSDManager.swift +++ b/DynamicIsland/managers/SystemOSDManager.swift @@ -17,12 +17,23 @@ */ import Foundation +import os class SystemOSDManager { private init() {} + // Tracks the PID we most recently suspended. macOS jetsam-exits OSDUIHelper + // when idle and launchd respawns it on the next media-key press as a fresh + // process, so we need to re-SIGSTOP every new incarnation. + private struct SuppressionState { + var task: Task? + var lastSuspendedPID: Int32 = -1 + } + private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState()) + /// Re-enables the system HUD by restarting OSDUIHelper public static func enableSystemHUD() { + stopSuppressionWatcher() Task.detached(priority: .background) { await enableSystemHUDAsync() } @@ -77,11 +88,14 @@ class SystemOSDManager { } } - /// Disables the system HUD by stopping OSDUIHelper + /// Disables the system HUD by stopping OSDUIHelper, and starts a + /// background watcher that re-suspends any future incarnation launchd + /// spawns (macOS auto-exits OSDUIHelper on idle). public static func disableSystemHUD() { Task.detached(priority: .background) { await disableSystemHUDAsync() } + startSuppressionWatcher() } private static func disableSystemHUDAsync() async { @@ -92,15 +106,35 @@ class SystemOSDManager { kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"] try kickstart.run() kickstart.waitUntilExit() - - try await Task.sleep(nanoseconds: 500_000_000) // 500ms - async wait instead of usleep - - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") - task.arguments = ["-STOP", "OSDUIHelper"] - try task.run() - task.waitUntilExit() - + + // launchctl kickstart returns once the request is queued, not after + // OSDUIHelper has actually forked. At cold boot the helper can take + // a while to appear — a fixed sleep races and the SIGSTOP misses, + // letting the native OSD render on the first volume/brightness key. + // Poll for the PID up to ~5s, then suspend, and retry if launchd + // respawned a fresh copy between kickstart and SIGSTOP. + var attempts = 0 + while attempts < 3 { + let appeared = await waitForOSDUIHelper(timeoutMillis: 5000) + if !appeared { + await MainActor.run { + NSLog("⚠️ OSDUIHelper did not appear within timeout; retrying SIGSTOP anyway") + } + } + + suspendOSDUIHelper() + + // Settle, then confirm a process is actually present (and thus + // suspended). If none is running, launchd hasn't spawned it yet + // or the prior STOP raced — loop and try again. + try await Task.sleep(nanoseconds: 250_000_000) // 250ms + if let pid = osduiHelperPID() { + suppressionState.withLock { $0.lastSuspendedPID = pid } + break + } + attempts += 1 + } + await MainActor.run { print("✅ System HUD disabled") } @@ -110,7 +144,92 @@ class SystemOSDManager { } } } - + + /// Polls for an OSDUIHelper process, returning true as soon as one appears + /// or false if `timeoutMillis` elapses with no match. + private static func waitForOSDUIHelper(timeoutMillis: Int) async -> Bool { + let pollIntervalNanos: UInt64 = 200_000_000 // 200ms + let maxAttempts = max(1, timeoutMillis / 200) + for _ in 0.. Task? in + let prior = state.task + state.task = newTask + return prior + } + previous?.cancel() + } + + private static func stopSuppressionWatcher() { + let previous = suppressionState.withLock { state -> Task? in + let prior = state.task + state.task = nil + state.lastSuspendedPID = -1 + return prior + } + previous?.cancel() + } + + /// Returns the newest OSDUIHelper PID, or nil if none. + private static func osduiHelperPID() -> Int32? { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + task.arguments = ["-n", "OSDUIHelper"] + let pipe = Pipe() + task.standardOutput = pipe + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let trimmed = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return Int32(trimmed) + } catch { + return nil + } + } + + /// Sends SIGSTOP to all OSDUIHelper processes. Idempotent. + private static func suspendOSDUIHelper() { + let stop = Process() + stop.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + stop.arguments = ["-STOP", "OSDUIHelper"] + do { + try stop.run() + stop.waitUntilExit() + } catch { + NSLog("Suppression watcher: failed to SIGSTOP OSDUIHelper: \(error)") + } + } + /// Check if OSDUIHelper is currently running public static func isOSDUIHelperRunning() -> Bool { let task = Process() From 2e796b08a70b2d2a9b392302af853276d8e558a0 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Tue, 12 May 2026 23:27:23 +0530 Subject: [PATCH 15/18] Atoll: add in-app Spotify login sheet for sp_dc cookie capture Wraps accounts.spotify.com login in a WKWebView so users can sign in without opening the browser inspector to copy the sp_dc cookie. The sheet uses a non-persistent data store, observes the cookie store, and hands the captured value to the existing SpotifyAuthManager.validate flow on a match. Spotify's anti-bot blocks the WebView path for some accounts, so this sits alongside (not replaces) the manual paste field, with an "Open in Browser" escape hatch in the sheet toolbar. Signed-off-by: Jis G Jacob --- DynamicIsland/Localizable.xcstrings | 58 ++-- .../Settings/SpotifyAuthSettingsSection.swift | 49 ++-- .../Settings/SpotifyLoginSheet.swift | 247 ++++++++++++++++++ 3 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 DynamicIsland/components/Settings/SpotifyLoginSheet.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9c4d703..f46d0b3 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,7 +2314,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3150,7 +3149,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3708,7 +3706,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5635,6 +5632,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6028,7 +6029,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6936,7 +6936,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -11679,6 +11678,10 @@ } } }, + "Connect Spotify" : { + "comment" : "A heading for a Spotify login sheet.", + "isCommentAutoGenerated" : true + }, "Connected" : { "extractionState" : "stale", "localizations" : { @@ -14542,7 +14545,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20319,6 +20321,10 @@ } } }, + "Get the cookie manually" : { + "comment" : "A section that provides instructions on how to get the Spotify cookie manually.", + "isCommentAutoGenerated" : true + }, "Get your free API key from Google AI Studio" : { "localizations" : { "fr" : { @@ -20853,7 +20859,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21598,10 +21603,6 @@ } } }, - "How to get it:" : { - "comment" : "A heading for the instructions on how to get the Spotify cookie.", - "isCommentAutoGenerated" : true - }, "https://example.com/animation.json" : { "comment" : "A placeholder text for a Lottie JSON URL input field.", "isCommentAutoGenerated" : true, @@ -29979,7 +29980,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30386,6 +30386,10 @@ } } }, + "Open in Browser" : { + "comment" : "A button that opens Spotify in the user's browser.", + "isCommentAutoGenerated" : true + }, "Open in Calendar" : { "comment" : "A button label that opens a calendar with the event details.", "isCommentAutoGenerated" : true, @@ -30732,7 +30736,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30837,10 +30840,6 @@ "comment" : "A button that copies the `sp_dc` cookie from the clipboard.", "isCommentAutoGenerated" : true }, - "Paste the `sp_dc` cookie from your logged-in Spotify web session." : { - "comment" : "A description of how to paste the Spotify cookie.", - "isCommentAutoGenerated" : true - }, "Pause" : { "localizations" : { "ru" : { @@ -33653,6 +33652,10 @@ } } }, + "Reset Session" : { + "comment" : "A button that clears the Spotify login session.", + "isCommentAutoGenerated" : true + }, "Reset Timer" : { "localizations" : { "ko" : { @@ -34289,7 +34292,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35811,7 +35813,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38247,7 +38248,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38272,7 +38272,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38389,6 +38388,14 @@ } } }, + "Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below." : { + "comment" : "A description of how to sign in to Spotify to get the `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, + "Sign in with Spotify" : { + "comment" : "A button that opens a sheet for signing in with Spotify.", + "isCommentAutoGenerated" : true + }, "Simple and minimal interface with just the essentials for everyday tasks." : { "localizations" : { "ko" : { @@ -38500,7 +38507,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39228,7 +39234,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39255,6 +39260,10 @@ "comment" : "A section header for the Spotify Canvas session settings.", "isCommentAutoGenerated" : true }, + "Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings." : { + "comment" : "A help message for the Spotify login sheet.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -39469,7 +39478,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42297,7 +42305,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45673,7 +45680,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift index 28e7b55..890e14a 100644 --- a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -23,6 +23,7 @@ import SwiftUI struct SpotifyAuthSettingsSection: View { @Default(.spotifySPDCCookie) private var spotifySPDCCookie @ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared + @State private var showingLoginSheet = false private var hasCookie: Bool { !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty @@ -31,10 +32,17 @@ struct SpotifyAuthSettingsSection: View { var body: some View { Section { VStack(alignment: .leading, spacing: 8) { - Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.") + Text("Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below.") .font(.caption) .foregroundStyle(.secondary) + Button { + showingLoginSheet = true + } label: { + Label("Sign in with Spotify", systemImage: "person.crop.circle.badge.checkmark") + } + .buttonStyle(.borderedProminent) + TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical) .textFieldStyle(.roundedBorder) .font(.caption.monospaced()) @@ -77,25 +85,25 @@ struct SpotifyAuthSettingsSection: View { .disabled(!hasCookie && !spotifyAuthManager.isAuthenticated) } - VStack(alignment: .leading, spacing: 6) { - Text("How to get it:") - .font(.caption) - .foregroundStyle(.secondary) + DisclosureGroup("Get the cookie manually") { + VStack(alignment: .leading, spacing: 6) { + Text("1. Open Spotify in a browser and log in") + .font(.caption) + Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") + .font(.caption) + Text("3. Copy the value of `sp_dc` and paste it here") + .font(.caption) - Text("1. Open Spotify in a browser and log in") + HStack(spacing: 12) { + Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) + Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) + } .font(.caption) - Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") - .font(.caption) - Text("3. Copy the value of `sp_dc` and paste it here") - .font(.caption) - - HStack(spacing: 12) { - Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) - Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) } - .font(.caption) + .padding(.top, 4) } - .padding(.top, 2) + .font(.caption) + .foregroundStyle(.secondary) } header: { Text("Spotify Canvas Session") } footer: { @@ -103,6 +111,15 @@ struct SpotifyAuthSettingsSection: View { .foregroundStyle(.secondary) .font(.caption) } + .sheet(isPresented: $showingLoginSheet) { + SpotifyLoginSheet { capturedValue in + let sanitized = SpotifyAuthManager.sanitizeCookie(capturedValue) + spotifySPDCCookie = sanitized + Task { + await spotifyAuthManager.validateSession() + } + } + } } private func pasteCookieFromClipboard() { diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift new file mode 100644 index 0000000..7c326f5 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -0,0 +1,247 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import os +import SwiftUI +@preconcurrency import WebKit + +private enum SpotifyLoginConstants { + static let loginURL = URL(string: "https://accounts.spotify.com/en/login")! + static let externalLoginURL = URL(string: "https://open.spotify.com/")! + static let cookieInstructionsURL = URL( + string: "https://github.com/Paxsenix0/Spotify-Canvas-API#how-to-get-sp_dc-cookie" + )! + static let cookieDomainSuffix = "spotify.com" + static let cookieName = "sp_dc" + // Chrome UA — Spotify's anti-bot heuristics block "Safari" WebViews more + // aggressively than they block Chromium-shaped ones in practice. + static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" +} + +private let spotifyLoginLogger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SpotifyLogin") + +struct SpotifyLoginSheet: View { + @Environment(\.dismiss) private var dismiss + + let onCapture: (String) -> Void + + @State private var statusText: String = "Sign in to Spotify to finish setup." + @State private var didCapture = false + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Connect Spotify") + .font(.headline) + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + NSWorkspace.shared.open(SpotifyLoginConstants.externalLoginURL) + } label: { + Label("Open in Browser", systemImage: "safari") + } + .help("Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings.") + Button("Reset Session") { + NotificationCenter.default.post(name: .spotifyLoginSheetReset, object: nil) + statusText = "Session cleared. Sign in again to capture the cookie." + } + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(12) + Divider() + SpotifyLoginWebView { value in + guard !didCapture else { return } + didCapture = true + statusText = "Captured sp_dc cookie. Validating…" + onCapture(value) + dismiss() + } onStatus: { message in + statusText = message + } + } + .frame(minWidth: 520, minHeight: 640) + } +} + +extension Notification.Name { + static let spotifyLoginSheetReset = Notification.Name("SpotifyLoginSheetReset") +} + +struct SpotifyLoginWebView: NSViewRepresentable { + let onCapture: (String) -> Void + let onStatus: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCapture: onCapture, onStatus: onStatus) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + + let webView = FirstClickWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = SpotifyLoginConstants.userAgent + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + + context.coordinator.attach(webView: webView) + webView.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + DispatchQueue.main.async { [weak webView] in + webView?.window?.makeFirstResponder(webView) + } + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { + coordinator.detach() + } + + private final class FirstClickWebView: WKWebView { + // SwiftUI sheets present the WKWebView with the host window already key, but + // AppKit still treats the webview as inactive until something inside it gets + // explicit focus. Without this override the first mouseDown is consumed + // making the view first responder rather than dispatching the click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + } + + @MainActor + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKHTTPCookieStoreObserver { + private let onCapture: (String) -> Void + private let onStatus: (String) -> Void + private weak var webView: WKWebView? + private var resetObserver: NSObjectProtocol? + + init(onCapture: @escaping (String) -> Void, onStatus: @escaping (String) -> Void) { + self.onCapture = onCapture + self.onStatus = onStatus + super.init() + resetObserver = NotificationCenter.default.addObserver( + forName: .spotifyLoginSheetReset, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.resetSession() } + } + } + + deinit { + if let resetObserver { + NotificationCenter.default.removeObserver(resetObserver) + } + } + + func attach(webView: WKWebView) { + self.webView = webView + webView.configuration.websiteDataStore.httpCookieStore.add(self) + } + + func detach() { + webView?.configuration.websiteDataStore.httpCookieStore.remove(self) + webView = nil + } + + private func resetSession() { + guard let webView else { return } + let store = webView.configuration.websiteDataStore + let types = WKWebsiteDataStore.allWebsiteDataTypes() + store.removeData(ofTypes: types, modifiedSince: .distantPast) { [weak self] in + Task { @MainActor in + self?.webView?.load(URLRequest(url: SpotifyLoginConstants.loginURL)) + } + } + } + + // MARK: - WKHTTPCookieStoreObserver + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { [weak self] cookies in + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let host = webView.url?.host ?? "" + spotifyLoginLogger.debug("didFinish host=\(host, privacy: .public)") + if host.contains("accounts.spotify.com") { + onStatus("Sign in to your Spotify account.") + } else if host.contains("open.spotify.com") { + onStatus("Open Spotify shouldn't load here — capture should fire before this. Falling back.") + } else if host.contains("www.spotify.com") || host.contains("spotify.com") { + onStatus("Logged in. Finishing capture…") + } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + let names = cookies + .filter { $0.domain.hasSuffix("spotify.com") } + .map { "\($0.name)@\($0.domain)" } + .joined(separator: ", ") + spotifyLoginLogger.debug("cookies after load: \(names, privacy: .public)") + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + onStatus("Load failed: \(error.localizedDescription)") + } + + // Allow pop-ups (e.g. SSO providers) to load inline in the same webview. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil, let url = navigationAction.request.url { + webView.load(URLRequest(url: url)) + } + return nil + } + + // MARK: - Capture + + private func handle(cookies: [HTTPCookie]) { + let match = cookies.first { cookie in + cookie.name == SpotifyLoginConstants.cookieName + && cookie.domain.hasSuffix(SpotifyLoginConstants.cookieDomainSuffix) + } + guard let match, !match.value.isEmpty else { return } + onCapture(match.value) + } + } +} From 1dffdd05f27540287f1eefb1c5185cf89a101332 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 14 May 2026 08:26:23 +0530 Subject: [PATCH 16/18] Atoll: freeze Spotify Canvas wallpaper when audio is paused SIGSTOP WallpaperAgent and the aerial extensions while MusicManager reports paused so the lockscreen Canvas holds its current frame, and SIGCONT on resume, hide(), and willTerminate so the agent is never left suspended. Signed-off-by: Jis G Jacob --- .../FullScreenArtworkWindowManager.swift | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift index f790e8b..ef80189 100644 --- a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -89,6 +89,14 @@ private final class LoopingVideoView: NSView { playerLayer?.removeFromSuperlayer() playerLayer = nil } + + func pause() { + queuePlayer?.pause() + } + + func resume() { + queuePlayer?.play() + } } private final class WallpaperTransitionImageView: NSView { @@ -304,6 +312,9 @@ final class FullScreenArtworkWindowManager: ObservableObject { private var trackChangeCancellable: AnyCancellable? private var artworkCacheCancellable: AnyCancellable? private var videoArtworkCancellable: AnyCancellable? + private var playbackStateCancellable: AnyCancellable? + private var hasSuspendedWallpaperAgent = false + private var appTerminationObserver: NSObjectProtocol? private var clickWindow: ClickReceiverWindow? private var clickWindowDelegated = false private var videoWindow: NSWindow? @@ -339,6 +350,8 @@ final class FullScreenArtworkWindowManager: ObservableObject { observeArtworkLayoutOverCanvasPreference() observeLyricsChanges() observeLyricsPreference() + observePlaybackStateChanges() + observeAppTermination() } func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { @@ -356,6 +369,7 @@ final class FullScreenArtworkWindowManager: ObservableObject { func hide() { guard isShowing else { return } + resumeWallpaperAgentIfNeeded() isShowing = false isLiveWallpaperAllowed = false activeLiveWallpaperFingerprint = nil @@ -426,6 +440,89 @@ final class FullScreenArtworkWindowManager: ObservableObject { } } + private func observePlaybackStateChanges() { + playbackStateCancellable = MusicManager.shared.$isPlaying + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying in + self?.applyCanvasPlaybackState(isPlaying: isPlaying) + } + } + + private static let wallpaperRenderProcessNames: [String] = [ + "WallpaperAgent", + "WallpaperAerialsExtension", + "WallpaperVideoExtension", + "wallpaperexportd" + ] + + private func applyCanvasPlaybackState(isPlaying: Bool) { + if let videoView, videoWindow != nil { + if isPlaying { + videoView.resume() + } else { + videoView.pause() + } + } + + let canSuspend = isShowing && isLiveWallpaperAllowed && activeLiveWallpaperFingerprint != nil + print("[FullScreenArtworkWindowManager] playback state -> isPlaying=\(isPlaying) showing=\(isShowing) liveAllowed=\(isLiveWallpaperAllowed) fingerprint=\(activeLiveWallpaperFingerprint ?? "nil")") + + if isPlaying { + resumeWallpaperAgentIfNeeded() + } else if canSuspend { + suspendWallpaperAgent() + } + } + + private func suspendWallpaperAgent() { + guard !hasSuspendedWallpaperAgent else { return } + var anySucceeded = false + for name in Self.wallpaperRenderProcessNames { + if signalProcess(name: name, signalFlag: "-STOP") { + anySucceeded = true + } + } + hasSuspendedWallpaperAgent = anySucceeded + print("[FullScreenArtworkWindowManager] SIGSTOP wallpaper processes -> anySucceeded=\(anySucceeded)") + } + + private func resumeWallpaperAgentIfNeeded() { + guard hasSuspendedWallpaperAgent else { return } + for name in Self.wallpaperRenderProcessNames { + _ = signalProcess(name: name, signalFlag: "-CONT") + } + hasSuspendedWallpaperAgent = false + print("[FullScreenArtworkWindowManager] SIGCONT wallpaper processes") + } + + @discardableResult + private func signalProcess(name: String, signalFlag: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = [signalFlag, name] + task.standardError = Pipe() + task.standardOutput = Pipe() + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + print("[FullScreenArtworkWindowManager] killall \(signalFlag) \(name) threw: \(error)") + return false + } + } + + private func observeAppTermination() { + appTerminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.resumeWallpaperAgentIfNeeded() + } + } + private func applyPresentation( artwork: NSImage, videoURL: URL?, @@ -1447,6 +1544,7 @@ final class FullScreenArtworkWindowManager: ObservableObject { activeVideoWindowURL = videoURL } window.orderFrontRegardless() + applyCanvasPlaybackState(isPlaying: MusicManager.shared.isPlaying) } private func scheduleHideVideoWindow(after delay: Duration, expectedURL: URL?) { From a92ee9c9b33539f4749b8006f298bf7be4f1fac2 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 14 May 2026 08:35:41 +0530 Subject: [PATCH 17/18] Atoll: block embedded Google OAuth in Spotify login sheet Google's "secure browsers" policy refuses OAuth inside any WKWebView regardless of UA, so signing into Spotify with a Google-linked account in the in-app sheet dead-ended on Google's "browser may be insecure" page. Intercept navigations to accounts.google.com / accounts.youtube.com in both decidePolicyFor and the createWebViewWith popup path, redirect back to Spotify's login screen, and surface a status message pointing users at email/password/Apple/Facebook sign-in or the existing "Open in Browser" escape hatch for Google accounts. Update the sheet's initial banner so the limitation is visible before the user tries it. Signed-off-by: Jis G Jacob --- .../Settings/SpotifyLoginSheet.swift | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift index 7c326f5..7eecc8d 100644 --- a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -29,11 +29,11 @@ private enum SpotifyLoginConstants { )! static let cookieDomainSuffix = "spotify.com" static let cookieName = "sp_dc" - // Chrome UA — Spotify's anti-bot heuristics block "Safari" WebViews more - // aggressively than they block Chromium-shaped ones in practice. static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + static let blockedOAuthHostSuffixes = ["accounts.google.com", "accounts.youtube.com"] + static let googleSignInRedirectURL = URL(string: "https://accounts.spotify.com/en/login")! } private let spotifyLoginLogger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SpotifyLogin") @@ -43,7 +43,8 @@ struct SpotifyLoginSheet: View { let onCapture: (String) -> Void - @State private var statusText: String = "Sign in to Spotify to finish setup." + @State private var statusText: String = + "Sign in with email & password, Apple, or Facebook. Google blocks in-app sign-in — use “Open in Browser” for Google accounts." @State private var didCapture = false var body: some View { @@ -124,10 +125,6 @@ struct SpotifyLoginWebView: NSViewRepresentable { } private final class FirstClickWebView: WKWebView { - // SwiftUI sheets present the WKWebView with the host window already key, but - // AppKit still treats the webview as inactive until something inside it gets - // explicit focus. Without this override the first mouseDown is consumed - // making the view first responder rather than dispatching the click. override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } @@ -178,8 +175,6 @@ struct SpotifyLoginWebView: NSViewRepresentable { } } - // MARK: - WKHTTPCookieStoreObserver - func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { cookieStore.getAllCookies { [weak self] cookies in Task { @MainActor in @@ -190,6 +185,30 @@ struct SpotifyLoginWebView: NSViewRepresentable { // MARK: - WKNavigationDelegate + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if let url = navigationAction.request.url, Self.isBlockedOAuthHost(url.host) { + decisionHandler(.cancel) + spotifyLoginLogger.debug( + "Blocked embedded OAuth navigation to host=\(url.host ?? "", privacy: .public)" + ) + onStatus( + "Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings." + ) + webView.load(URLRequest(url: SpotifyLoginConstants.googleSignInRedirectURL)) + return + } + decisionHandler(.allow) + } + + private static func isBlockedOAuthHost(_ host: String?) -> Bool { + guard let host else { return false } + return SpotifyLoginConstants.blockedOAuthHostSuffixes.contains { host.hasSuffix($0) } + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { let host = webView.url?.host ?? "" spotifyLoginLogger.debug("didFinish host=\(host, privacy: .public)") @@ -228,6 +247,12 @@ struct SpotifyLoginWebView: NSViewRepresentable { windowFeatures: WKWindowFeatures ) -> WKWebView? { if navigationAction.targetFrame == nil, let url = navigationAction.request.url { + if Self.isBlockedOAuthHost(url.host) { + onStatus( + "Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings." + ) + return nil + } webView.load(URLRequest(url: url)) } return nil From e14945645a61b884d3fde35189a0b5699ee6ad36 Mon Sep 17 00:00:00 2001 From: Jis G Jacob Date: Thu, 14 May 2026 20:29:32 +0530 Subject: [PATCH 18/18] Atoll: Revert pbx changes did in 5bd84270aae590e5f0ed93331dd7e8ecc457a428 Signed-off-by: Jis G Jacob --- DynamicIsland.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 3d4e01b..02e0162 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -454,12 +454,12 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = RPC964BP8A; + DEVELOPMENT_TEAM = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -526,13 +526,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES;