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; 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/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 67ed55a..4548a50 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2440,6 +2440,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" : { @@ -2513,6 +2517,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" : { @@ -2548,6 +2556,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" : { @@ -6389,6 +6401,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, @@ -7074,6 +7090,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" : { @@ -9043,6 +9062,9 @@ }, "Charging HUD" : { + }, + "Charging on hold" : { + "comment" : "Plugged in but charging is paused by the system" }, "Charging on Hold: Desktop Mode" : { "localizations" : { @@ -9963,7 +9985,6 @@ } }, "Clear" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -11679,6 +11700,10 @@ } } }, + "Connect Spotify" : { + "comment" : "A heading for a Spotify login sheet.", + "isCommentAutoGenerated" : true + }, "Connected" : { "extractionState" : "stale", "localizations" : { @@ -19833,6 +19858,9 @@ } } } + }, + "Fullscreen artwork on right-click" : { + }, "Fully charged" : { "comment" : "Charging fallback label when already charged", @@ -20330,6 +20358,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" : { @@ -22869,6 +22901,9 @@ } } } + }, + "Keep album art visible during fullscreen artwork" : { + }, "Keep terminal open until clicked outside" : { "localizations" : { @@ -26325,6 +26360,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, @@ -30186,6 +30225,9 @@ } } }, + "On hold" : { + "comment" : "Plugged in but charging is paused by the system" + }, "One or more files failed to load" : { "localizations" : { "ar" : { @@ -30402,6 +30444,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, @@ -30490,6 +30536,10 @@ } } }, + "Open Spotify Web Player" : { + "comment" : "A link to open Spotify.", + "isCommentAutoGenerated" : true + }, "Open System Settings" : { "localizations" : { "ko" : { @@ -30844,6 +30894,10 @@ } } }, + "Paste from Clipboard" : { + "comment" : "A button that copies the `sp_dc` cookie from the clipboard.", + "isCommentAutoGenerated" : true + }, "Pause" : { "localizations" : { "ru" : { @@ -33306,6 +33360,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" : { @@ -33653,6 +33710,10 @@ } } }, + "Reset Session" : { + "comment" : "A button that clears the Spotify login session.", + "isCommentAutoGenerated" : true + }, "Reset Timer" : { "localizations" : { "ko" : { @@ -34283,6 +34344,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. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas." : { + }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", @@ -37345,6 +37409,9 @@ } } } + }, + "Show live canvas in Dynamic Island" : { + }, "Show location label" : { "localizations" : { @@ -38382,6 +38449,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" : { @@ -39085,6 +39160,10 @@ } } }, + "sp_dc cookie" : { + "comment" : "A label for the Spotify `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, "Speed" : { "localizations" : { "ar" : { @@ -39238,6 +39317,14 @@ } } }, + "Spotify Canvas Session" : { + "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" : { @@ -43416,6 +43503,9 @@ } } } + }, + "Use album art layout over fullscreen canvas" : { + }, "Use circular battery indicator" : { "localizations" : { @@ -43955,6 +44045,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, diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 0380d96..5950ada 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,20 +155,40 @@ 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 + let isSameCanvasTrack = trackURI == currentCanvasTrackURI + + if isSameCanvasTrack { + state.liveArtworkURL = playbackState.liveArtworkURL + } else { + state.liveArtworkURL = nil + 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 } artworkFetchTask?.cancel() let currentState = state + let expectedTrackURI = trackURI artworkFetchTask = Task { do { @@ -150,8 +196,13 @@ 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 self.playbackState = updatedState self.lastArtworkURL = artworkURL self.artworkFetchTask = nil @@ -164,9 +215,40 @@ class SpotifyController: MediaControllerProtocol { } } } - -// 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 +259,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 +274,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..f9a86dc 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 @@ -46,6 +47,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 +68,9 @@ struct LockScreenMusicPanel: View { @Default(.enableLyrics) private var enableLyrics @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) { _animator = ObservedObject(wrappedValue: animator) @@ -118,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 { @@ -132,16 +153,27 @@ 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)) .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 ? Color.black.opacity(0.35) : Color.black.opacity(0.3), + radius: usesSpotifyCanvasFallbackContentPresentation ? 26 : 20, + x: 0, + y: usesSpotifyCanvasFallbackContentPresentation ? 14 : 10 + ) + .contentShape(Rectangle()) .animation(.easeInOut(duration: 0.28), value: isExpanded) .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .onAppear { @@ -163,6 +195,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() } @@ -222,9 +259,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 ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20)) + .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16)) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading + ) } private var collapsedLayout: some View { @@ -239,25 +280,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) { @@ -329,30 +385,48 @@ 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) + let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius) - .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) - .offset(x: appIconOffset, y: appIconOffset) - .transition(.scale.combined(with: .opacity)) - } + return ZStack(alignment: .bottomTrailing) { + if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { + RoundedRectangle(cornerRadius: artworkCornerRadius, 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 && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle) + .parallax3D( + enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork), + suspended: isParallaxSuspended + ) + .frame(width: size) + .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) + .animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen) + .onTapGesture { + toggleExpanded() + } + .onRightClick { + expandArtworkToFullscreen() + } } @ViewBuilder @@ -384,6 +458,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 } @@ -484,7 +593,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) @@ -505,7 +614,7 @@ struct LockScreenMusicPanel: View { } } - if enableLyrics { + if shouldShowInlineLyrics { lyricsSection(alignment: alignment) .transition(.move(edge: .top).combined(with: .opacity)) } @@ -949,7 +1058,7 @@ struct LockScreenMusicPanel: View { } private var lyricsExtraHeight: CGFloat { - lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) + lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics) } private var accessorySectionExtraHeight: CGFloat { @@ -979,7 +1088,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) { @@ -1034,7 +1143,9 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackgroundLayer: some View { - if usesCustomLiquidGlass { + if usesSpotifyCanvasFallbackContentPresentation { + canvasFallbackScrim + } else if usesCustomLiquidGlass { customLiquidPanelBackdrop } else if usesStandardLiquidGlass { standardLiquidPanelBackdrop @@ -1045,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, *) { @@ -1132,7 +1268,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 @@ -1170,6 +1307,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) @@ -1384,3 +1528,87 @@ 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)? + private var eventMonitor: Any? + + 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 + } +} + +extension Notification.Name { + static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed") +} diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 362c853..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() } @@ -195,8 +181,7 @@ struct LockScreenTimerWidget: View { controlButtons .padding(.trailing, 12) - titleSection - .frame(maxWidth: .infinity) + Spacer() countdownSection .padding(.leading, 12) @@ -261,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/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 { 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 9e4def1..d630ae9 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -776,6 +776,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")), @@ -2666,6 +2667,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 @@ -2721,6 +2723,11 @@ struct Media: View { .font(.caption) } } + + if mediaController == .spotify { + SpotifyAuthSettingsSection() + } + Section { Defaults.Toggle(key: .showStandardMediaControls) { Text("Show media controls in Dynamic Island") @@ -2819,6 +2826,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) { @@ -2907,6 +2919,27 @@ 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: .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. 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) + } } header: { Text("Lock Screen Integration") } footer: { @@ -4787,6 +4820,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 @@ -5020,6 +5054,27 @@ 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: .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. 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) + } if !showStandardMediaControls { Text("Enable Dynamic Island media controls to manage the lock screen panel.") diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift new file mode 100644 index 0000000..890e14a --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -0,0 +1,129 @@ +/* + * 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 + @State private var showingLoginSheet = false + + private var hasCookie: Bool { + !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty + } + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + 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()) + .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) + } + + 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) + + 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) + } + .font(.caption) + .foregroundStyle(.secondary) + } 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) + } + .sheet(isPresented: $showingLoginSheet) { + SpotifyLoginSheet { capturedValue in + let sanitized = SpotifyAuthManager.sanitizeCookie(capturedValue) + spotifySPDCCookie = sanitized + Task { + await spotifyAuthManager.validateSession() + } + } + } + } + + private func pasteCookieFromClipboard() { + guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return } + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText) + } +} diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift new file mode 100644 index 0000000..7eecc8d --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -0,0 +1,272 @@ +/* + * 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" + 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") + +struct SpotifyLoginSheet: View { + @Environment(\.dismiss) private var dismiss + + let onCapture: (String) -> Void + + @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 { + 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 { + 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)) + } + } + } + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { [weak self] cookies in + Task { @MainActor in + self?.handle(cookies: cookies) + } + } + } + + // 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)") + 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 { + 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 + } + + // 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) + } + } +} 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/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift new file mode 100644 index 0000000..ef80189 --- /dev/null +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -0,0 +1,1801 @@ +/* + * 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 CoreImage +import Defaults +import SkyLightWindow +import SwiftUI + +// MARK: - Click Receiver Window + +private class ClickReceiverWindow: NSWindow { + var onClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + // Keep left click inert so the fallback only dismisses on right click. + } + + 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 + } + + func pause() { + queuePlayer?.pause() + } + + func resume() { + queuePlayer?.play() + } +} + +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: ObservableObject { + private enum ArtworkOverlayMode { + case none + case spotifyFallback + } + + static let shared = FullScreenArtworkWindowManager() + + @Published private(set) var isShowing = false + @Published private(set) var isShowingSpotifyCanvasFallback = 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 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? + 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? + 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 pendingFallbackStaticURL: URL? + private var pendingFallbackWallpaperKey: 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() + observePlaybackStateChanges() + observeAppTermination() + } + + func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { + guard !isShowing else { return } + guard let screen = NSScreen.main else { return } + applyPresentation( + artwork: artwork, + videoURL: videoURL, + allowLiveWallpaper: allowLiveWallpaper, + on: screen, + backupWallpaperConfiguration: true + ) + observeTrackChanges() + } + + func hide() { + guard isShowing else { return } + resumeWallpaperAgentIfNeeded() + isShowing = false + isLiveWallpaperAllowed = false + activeLiveWallpaperFingerprint = nil + activeWallpaperKey = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = 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 + 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(180), scheduler: DispatchQueue.main) + .sink { [weak self] newArt in + guard let self 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 } + try? pngData.write(to: url, options: .atomic) + await MainActor.run { + self.cachedArtworkPNG = url + self.cachedArtworkFingerprint = fingerprint + if self.isShowing { + self.refreshPresentationForCurrentTrack() + } + } + } + } + } + + private func observeVideoArtworkChanges() { + videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .debounce(for: .milliseconds(120), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + self.refreshPresentationForCurrentTrack() + } + } + + 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?, + 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 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 || expectCanvas, + screen: screen + ) + let desiredLiveWallpaperFingerprint = effectiveVideoURL?.absoluteString + let requiresWallpaperRefresh = backupWallpaperConfiguration + || activeWallpaperKey != wallpaperKey + || activeLiveWallpaperFingerprint != desiredLiveWallpaperFingerprint + + artworkFileURL = wallpaperURL + + if backupWallpaperConfiguration { + backupWallpaperConfig() + } + + if requiresWallpaperRefresh { + 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 + } + + restartWallpaperAgent() + activeWallpaperKey = wallpaperKey + activeLiveWallpaperFingerprint = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = 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) + else { return nil } + 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 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 + + 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 + } + + if let screen = NSScreen.main { + showVideoWindow(on: screen, videoURL: videoURL) + } + scheduleWallpaperTransitionHide(after: .milliseconds(420)) + + 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)" + 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 { + 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") + } + } + } + + 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 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 + + 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 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 + 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) + if activeVideoWindowURL != videoURL { + view.play(url: videoURL) + activeVideoWindowURL = videoURL + } + window.orderFrontRegardless() + applyCanvasPlaybackState(isPlaying: MusicManager.shared.isPlaying) + } + + 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) + } + + // 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"] = "individual" + plist["AllSpacesAndDisplays"] = allSpaces + + var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] + systemDefault["Desktop"] = desktopBlock + systemDefault["Idle"] = idleBlock + 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 + if display["Type"] != nil { + display["Type"] = "individual" + } + 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 + if defaultEntry["Type"] != nil { + defaultEntry["Type"] = "individual" + } + 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 + if display["Type"] != nil { + display["Type"] = "individual" + } + 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() + .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.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/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/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index c259971..e076fe7 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,74 @@ 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 + 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, + 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 +376,7 @@ class LockScreenPanelManager { LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main } } + +extension Notification.Name { + static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange") +} 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) 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 diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index d3f723d..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,10 +485,16 @@ 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? - 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 @@ -512,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() @@ -580,6 +603,8 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + lyricsFetchTask?.cancel() + lyricSyncTask?.cancel() explicitLookupTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() @@ -701,6 +726,7 @@ class MusicManager: ObservableObject { // Check for artwork changes let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + let hasContentChange = titleChanged || artistChanged @@ -709,6 +735,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,10 +766,16 @@ class MusicManager: ObservableObject { self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentURL = state.contentURL - // Fetch lyrics for new track whenever content changes - self.fetchLyrics() + self.prepareLyricsForCurrentTrack() + 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 if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { self.updateSneakPeek() @@ -1238,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 [] @@ -1298,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) ?? "" @@ -1340,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] = [] @@ -1424,6 +1618,32 @@ class MusicManager: ObservableObject { lyricSyncTask = nil } + // MARK: - Video Artwork + + func fetchVideoArtwork() { + guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else { + 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 + } + + let title = songTitle + let artist = artistName + + Task { + let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL( + title: title, artist: artist + ) + await MainActor.run { + self.videoArtworkURL = url + } + } + } + func toggleLyrics() { // Toggle the UI state first so the views can react immediately. showLyrics.toggle() @@ -1518,7 +1738,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 } } 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/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() diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index d29ff4e..86b4f34 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -926,6 +926,10 @@ 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 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") @@ -1014,6 +1018,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) @@ -1216,6 +1224,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) 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 } }