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