Merge pull request #451 from StudioKeys/pr-440

440-fixes
This commit is contained in:
Hariharan Mudaliar 2026-05-14 23:16:21 +05:30 • committed by GitHub
commit be09d93d2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 4218 additions and 214 deletions

View file

@ -454,12 +454,12 @@
AUTOMATION_APPLE_EVENTS = NO; AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = RPC964BP8A; DEVELOPMENT_TEAM = 9Y64TRM77N;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -526,13 +526,13 @@
AUTOMATION_APPLE_EVENTS = NO; AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A; "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View file

@ -7,6 +7,8 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSAppleMusicUsageDescription</key>
<string>Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper.</string>
<key>NSAudioCaptureUsageDescription</key> <key>NSAudioCaptureUsageDescription</key>
<string>Atoll needs audio capture permission to display the real-time music waveform visualizer.</string> <string>Atoll needs audio capture permission to display the real-time music waveform visualizer.</string>
<key>NSScreenCaptureUsageDescription</key> <key>NSScreenCaptureUsageDescription</key>

View file

@ -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" : { "1m" : {
"localizations" : { "localizations" : {
"ko" : { "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" : { "3 items" : {
"comment" : "merge:ko:alt='항목 3개'", "comment" : "merge:ko:alt='항목 3개'",
"localizations" : { "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" : { "5 colors" : {
"comment" : "merge:ko:alt='5가지 색상'", "comment" : "merge:ko:alt='5가지 색상'",
"localizations" : { "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." : { "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.", "comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.",
"isCommentAutoGenerated" : true, "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" : { "Battery charging" : {
"comment" : "Battery charging without estimate", "comment" : "Battery charging without estimate",
"localizations" : { "localizations" : {
@ -9043,6 +9062,9 @@
}, },
"Charging HUD" : { "Charging HUD" : {
},
"Charging on hold" : {
"comment" : "Plugged in but charging is paused by the system"
}, },
"Charging on Hold: Desktop Mode" : { "Charging on Hold: Desktop Mode" : {
"localizations" : { "localizations" : {
@ -9963,7 +9985,6 @@
} }
}, },
"Clear" : { "Clear" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -11679,6 +11700,10 @@
} }
} }
}, },
"Connect Spotify" : {
"comment" : "A heading for a Spotify login sheet.",
"isCommentAutoGenerated" : true
},
"Connected" : { "Connected" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@ -19833,6 +19858,9 @@
} }
} }
} }
},
"Fullscreen artwork on right-click" : {
}, },
"Fully charged" : { "Fully charged" : {
"comment" : "Charging fallback label when already 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" : { "Get your free API key from Google AI Studio" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -22869,6 +22901,9 @@
} }
} }
} }
},
"Keep album art visible during fullscreen artwork" : {
}, },
"Keep terminal open until clicked outside" : { "Keep terminal open until clicked outside" : {
"localizations" : { "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" : { "Mic" : {
"comment" : "Name of a sneak action type that controls the microphone.", "comment" : "Name of a sneak action type that controls the microphone.",
"isCommentAutoGenerated" : true, "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" : { "One or more files failed to load" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -30402,6 +30444,10 @@
} }
} }
}, },
"Open in Browser" : {
"comment" : "A button that opens Spotify in the user's browser.",
"isCommentAutoGenerated" : true
},
"Open in Calendar" : { "Open in Calendar" : {
"comment" : "A button label that opens a calendar with the event details.", "comment" : "A button label that opens a calendar with the event details.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -30490,6 +30536,10 @@
} }
} }
}, },
"Open Spotify Web Player" : {
"comment" : "A link to open Spotify.",
"isCommentAutoGenerated" : true
},
"Open System Settings" : { "Open System Settings" : {
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -30844,6 +30894,10 @@
} }
} }
}, },
"Paste from Clipboard" : {
"comment" : "A button that copies the `sp_dc` cookie from the clipboard.",
"isCommentAutoGenerated" : true
},
"Pause" : { "Pause" : {
"localizations" : { "localizations" : {
"ru" : { "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" : { "Request Access" : {
"localizations" : { "localizations" : {
@ -33653,6 +33710,10 @@
} }
} }
}, },
"Reset Session" : {
"comment" : "A button that clears the Spotify login session.",
"isCommentAutoGenerated" : true
},
"Reset Timer" : { "Reset Timer" : {
"localizations" : { "localizations" : {
"ko" : { "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" : { "Ring" : {
"comment" : "The name of the timer progress style that shows a ring shape.", "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" : { "Show location label" : {
"localizations" : { "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." : { "Simple and minimal interface with just the essentials for everyday tasks." : {
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -39085,6 +39160,10 @@
} }
} }
}, },
"sp_dc cookie" : {
"comment" : "A label for the Spotify `sp_dc` cookie.",
"isCommentAutoGenerated" : true
},
"Speed" : { "Speed" : {
"localizations" : { "localizations" : {
"ar" : { "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" : { "Square" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -43416,6 +43503,9 @@
} }
} }
} }
},
"Use album art layout over fullscreen canvas" : {
}, },
"Use circular battery indicator" : { "Use circular battery indicator" : {
"localizations" : { "localizations" : {
@ -43955,6 +44045,14 @@
}, },
"shouldTranslate" : false "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" : { "Variant %lld" : {
"comment" : "A label for a specific variant of the Liquid Glass.", "comment" : "A label for a specific variant of the Liquid Glass.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,

View file

@ -20,8 +20,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import Foundation
import Combine import Combine
import Foundation
import SwiftUI import SwiftUI
class SpotifyController: MediaControllerProtocol { class SpotifyController: MediaControllerProtocol {
@ -31,91 +31,117 @@ class SpotifyController: MediaControllerProtocol {
@Published private var playbackState: PlaybackState = PlaybackState( @Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: SpotifyController.bundleIdentifier bundleIdentifier: SpotifyController.bundleIdentifier
) )
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher() $playbackState.eraseToAnyPublisher()
} }
var isWorking: Bool { var isWorking: Bool {
return true // Spotify controller always works true
} }
private var notificationTask: Task<Void, Never>? private var notificationTask: Task<Void, Never>?
private var sessionChangeCancellable: AnyCancellable?
// Constant for time between command and update // Constant for time between command and update
private let commandUpdateDelay: Duration = .milliseconds(25) private let commandUpdateDelay: Duration = .milliseconds(25)
private var lastArtworkURL: String? private var lastArtworkURL: String?
private var artworkFetchTask: Task<Void, Never>? private var artworkFetchTask: Task<Void, Never>?
private var canvasFetchTask: Task<Void, Never>?
private var currentCanvasTrackURI: String?
private var lastCanvasRequestTrackURI: String?
private var lastCanvasRequestDate: Date = .distantPast
private let canvasResolver = SpotifyCanvasResolver()
init() { init() {
setupPlaybackStateChangeObserver() setupPlaybackStateChangeObserver()
setupSessionChangeObserver()
Task { Task {
if isActive() { if isActive() {
await updatePlaybackInfo() await updatePlaybackInfo()
} }
} }
} }
private func setupPlaybackStateChangeObserver() { private func setupPlaybackStateChangeObserver() {
notificationTask = Task { @Sendable [weak self] in notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications( let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
) )
for await _ in notifications { for await _ in notifications {
await self?.updatePlaybackInfo() 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 { deinit {
notificationTask?.cancel() notificationTask?.cancel()
artworkFetchTask?.cancel() artworkFetchTask?.cancel()
canvasFetchTask?.cancel()
sessionChangeCancellable?.cancel()
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
func play() async { await executeCommand("play") } func play() async { await executeCommand("play") }
func pause() async { await executeCommand("pause") } func pause() async { await executeCommand("pause") }
func togglePlay() async { await executeCommand("playpause") } func togglePlay() async { await executeCommand("playpause") }
func nextTrack() async { await executeCommand("next track") } func nextTrack() async { await executeCommand("next track") }
func previousTrack() async { func previousTrack() async {
await executeAndRefresh("previous track") await executeAndRefresh("previous track")
} }
func seek(to time: Double) async { func seek(to time: Double) async {
await executeAndRefresh("set player position to \(time)") await executeAndRefresh("set player position to \(time)")
} }
func toggleShuffle() async { func toggleShuffle() async {
await executeAndRefresh("set shuffling to not shuffling") await executeAndRefresh("set shuffling to not shuffling")
} }
func toggleRepeat() async { func toggleRepeat() async {
await executeAndRefresh("set repeating to not repeating") await executeAndRefresh("set repeating to not repeating")
} }
func isActive() -> Bool { func isActive() -> Bool {
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
} }
func updatePlaybackInfo() async { func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 11 else { return } guard descriptor.numberOfItems >= 11 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 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 isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? ""
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? ""
let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString)
var state = PlaybackState( var state = PlaybackState(
bundleIdentifier: Self.bundleIdentifier, bundleIdentifier: Self.bundleIdentifier,
isPlaying: isPlaying, isPlaying: isPlaying,
@ -129,20 +155,40 @@ class SpotifyController: MediaControllerProtocol {
repeatMode: isRepeating ? .all : .off, repeatMode: isRepeating ? .all : .off,
lastUpdated: Date() lastUpdated: Date()
) )
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL ? 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 { if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork 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) { if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
guard artworkURL != lastArtworkURL || state.artwork == nil else { return } guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
artworkFetchTask?.cancel() artworkFetchTask?.cancel()
let currentState = state let currentState = state
let expectedTrackURI = trackURI
artworkFetchTask = Task { artworkFetchTask = Task {
do { do {
@ -150,8 +196,13 @@ class SpotifyController: MediaControllerProtocol {
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else {
self.artworkFetchTask = nil
return
}
var updatedState = currentState var updatedState = currentState
updatedState.artwork = data updatedState.artwork = data
updatedState.liveArtworkURL = self.playbackState.liveArtworkURL
self.playbackState = updatedState self.playbackState = updatedState
self.lastArtworkURL = artworkURL self.lastArtworkURL = artworkURL
self.artworkFetchTask = nil 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 { private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)" let script = "tell application \"Spotify\" to \(command)"
try? await AppleScriptHelper.executeVoid(script) try? await AppleScriptHelper.executeVoid(script)
@ -177,7 +259,7 @@ class SpotifyController: MediaControllerProtocol {
try? await Task.sleep(for: commandUpdateDelay) try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo() await updatePlaybackInfo()
} }
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """ let script = """
tell application "Spotify" tell application "Spotify"
@ -192,21 +274,294 @@ class SpotifyController: MediaControllerProtocol {
set shuffleState to shuffling set shuffleState to shuffling
set repeatState to repeating set repeatState to repeating
set artworkURL to artwork url of current track set artworkURL to artwork url of current track
set trackIdentifier to "" set trackID to id of current track
try set spotifyURL to spotify url of current track
set trackIdentifier to id of current track return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL}
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}
on error on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
end try end try
end tell end tell
""" """
return try await AppleScriptHelper.execute(script) 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..<endIndex]
index = endIndex
return Data(slice)
}
mutating func readString() throws -> 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
}
} }

View file

@ -36,6 +36,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator @ObservedObject private var animator: LockScreenPanelAnimator
@ -46,6 +47,7 @@ struct LockScreenMusicPanel: View {
@State private var isExpanded = false @State private var isExpanded = false
@State private var isVolumeSliderVisible = false @State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false @State private var isAirPlayPopoverPresented = false
@State private var isArtworkFullscreen = false
@State private var collapseWorkItem: DispatchWorkItem? @State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem?
@ -66,6 +68,9 @@ struct LockScreenMusicPanel: View {
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
@Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
init(animator: LockScreenPanelAnimator) { init(animator: LockScreenPanelAnimator) {
_animator = ObservedObject(wrappedValue: animator) _animator = ObservedObject(wrappedValue: animator)
@ -118,6 +123,22 @@ struct LockScreenMusicPanel: View {
private var usesEnhancedCustomLiquidBorder: Bool { private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder 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 { var body: some View {
if isActive && musicManager.hasActiveSession { if isActive && musicManager.hasActiveSession {
@ -132,16 +153,27 @@ struct LockScreenMusicPanel: View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
panelBackgroundLayer panelBackgroundLayer
panelForeground 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) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay { .overlay {
if showPanelBorder { if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation {
panelBorderOverlay panelBorderOverlay
} }
} }
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .shadow(
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) 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.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear { .onAppear {
@ -163,6 +195,11 @@ struct LockScreenMusicPanel: View {
parallaxResumeWorkItem = nil parallaxResumeWorkItem = nil
isParallaxSuspended = false isParallaxSuspended = false
} }
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
}
.onChange(of: isExpanded) { _, expanded in .onChange(of: isExpanded) { _, expanded in
updatePanelSize() updatePanelSize()
} }
@ -222,9 +259,13 @@ struct LockScreenMusicPanel: View {
collapsedLayout collapsedLayout
} }
} }
.padding(.horizontal, isExpanded ? 24 : 20) .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20))
.padding(.vertical, isExpanded ? 22 : 16) .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading
)
} }
private var collapsedLayout: some View { private var collapsedLayout: some View {
@ -239,25 +280,40 @@ struct LockScreenMusicPanel: View {
} }
private var expandedLayout: some View { private var expandedLayout: some View {
HStack(alignment: .center, spacing: expandedContentSpacing) { Group {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) if hidesInlineArtworkForSpotifyCanvasFallback {
.frame(width: 230, height: 230) 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) { VStack(alignment: .leading, spacing: 20) {
expandedHeader expandedHeader
progressBar progressBar
.padding(.top, 10) .padding(.top, 10)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
playbackControls(alignment: .leading) playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.frame(maxHeight: .infinity, alignment: .center) .frame(maxHeight: .infinity, alignment: .center)
} }
private var collapsedHeader: some View { private var collapsedHeader: some View {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) if !hidesInlineArtworkForSpotifyCanvasFallback {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
}
GeometryReader { geo in GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
@ -329,30 +385,48 @@ struct LockScreenMusicPanel: View {
} }
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
Button(action: toggleExpanded) { let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius)
ZStack(alignment: .bottomTrailing) {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) return ZStack(alignment: .bottomTrailing) {
.offset(x: appIconOffset, y: appIconOffset) if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork {
.transition(.scale.combined(with: .opacity)) 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) .opacity(musicManager.isPlaying ? 1 : 0.4)
.scaleEffect(musicManager.isPlaying ? 1 : 0.85) .scaleEffect(musicManager.isPlaying ? 1 : 0.85)
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
.onTapGesture {
toggleExpanded()
}
.onRightClick {
expandArtworkToFullscreen()
}
} }
@ViewBuilder @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() { private func registerInteraction() {
cancelCollapseTimer() cancelCollapseTimer()
guard isExpanded else { return } guard isExpanded else { return }
@ -484,7 +593,7 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View { private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20 let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10 let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10
return VStack(spacing: verticalSpacing) { return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing) controlsRow(alignment: alignment, spacing: spacing)
@ -505,7 +614,7 @@ struct LockScreenMusicPanel: View {
} }
} }
if enableLyrics { if shouldShowInlineLyrics {
lyricsSection(alignment: alignment) lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
} }
@ -949,7 +1058,7 @@ struct LockScreenMusicPanel: View {
} }
private var lyricsExtraHeight: CGFloat { private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics)
} }
private var accessorySectionExtraHeight: CGFloat { private var accessorySectionExtraHeight: CGFloat {
@ -979,7 +1088,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight + accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics) lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics)
} }
private func updatePanelSize(animated: Bool = true) { private func updatePanelSize(animated: Bool = true) {
@ -1034,7 +1143,9 @@ struct LockScreenMusicPanel: View {
@ViewBuilder @ViewBuilder
private var panelBackgroundLayer: some View { private var panelBackgroundLayer: some View {
if usesCustomLiquidGlass { if usesSpotifyCanvasFallbackContentPresentation {
canvasFallbackScrim
} else if usesCustomLiquidGlass {
customLiquidPanelBackdrop customLiquidPanelBackdrop
} else if usesStandardLiquidGlass { } else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop 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 @ViewBuilder
private var standardLiquidPanelBackdrop: some View { private var standardLiquidPanelBackdrop: some View {
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
@ -1132,7 +1268,8 @@ struct LockScreenMusicPanel: View {
Image(nsImage: musicManager.albumArt) Image(nsImage: musicManager.albumArt)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .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 @ViewBuilder
@ -1170,6 +1307,13 @@ struct LockScreenMusicPanel: View {
isExpanded ? 18 : 12 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, *) @available(macOS 26.0, *)
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@ -1384,3 +1528,87 @@ private struct GlassTextBackdrop: View {
.accessibilityHidden(true) .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")
}

View file

@ -45,10 +45,6 @@ struct LockScreenTimerWidget: View {
} }
} }
private func displayFont(size: CGFloat) -> Font {
.system(size: size, weight: .semibold, design: .rounded)
}
private var hasHoursComponent: Bool { private var hasHoursComponent: Bool {
abs(timerManager.remainingTime) >= 3600 abs(timerManager.remainingTime) >= 3600
} }
@ -57,12 +53,6 @@ struct LockScreenTimerWidget: View {
abs(timerManager.remainingTime) >= 36_000 // 10 hours or more 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 { private var countdownFrameWidth: CGFloat {
if hasDoubleDigitHours { return 200 } if hasDoubleDigitHours { return 200 }
if hasHoursComponent { return 185 } if hasHoursComponent { return 185 }
@ -74,10 +64,6 @@ struct LockScreenTimerWidget: View {
return .system(size: baseSize, weight: .bold, design: .rounded) return .system(size: baseSize, weight: .bold, design: .rounded)
} }
private var timerLabel: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
}
private var countdownText: String { private var countdownText: String {
timerManager.formattedRemainingTime() timerManager.formattedRemainingTime()
} }
@ -195,8 +181,7 @@ struct LockScreenTimerWidget: View {
controlButtons controlButtons
.padding(.trailing, 12) .padding(.trailing, 12)
titleSection Spacer()
.frame(maxWidth: .infinity)
countdownSection countdownSection
.padding(.leading, 12) .padding(.leading, 12)
@ -261,19 +246,6 @@ struct LockScreenTimerWidget: View {
.layoutPriority(2) .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 { private var accentRibbon: some View {
Capsule() Capsule()
.fill(accentGradient) .fill(accentGradient)

View file

@ -1261,7 +1261,15 @@ struct LockScreenWeatherWidget: View {
private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String { private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if info.isPluggedIn && !info.isCharging { 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") return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
} }
@ -1456,7 +1464,17 @@ struct LockScreenWeatherWidget: View {
} }
if charging.isPluggedIn && !charging.isCharging { 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 { if snapshot.showsChargingPercentage, let level = charging.batteryLevel {

View file

@ -1113,8 +1113,17 @@ private struct MinimalisticReminderDetailsView: View {
struct MinimalisticAlbumArtView: View { struct MinimalisticAlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID 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 { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -1128,16 +1137,27 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.scaledToFill() contentMode: .fill
)
) )
.clipped() .clipped()
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius))
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 35) .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 { private var albumArtButton: some View {
@ -1147,13 +1167,10 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
DynamicIslandArtworkSourceView(
Image(nsImage: musicManager.albumArt) cornerRadius: albumArtCornerRadius,
.resizable() contentMode: .fit
.aspectRatio(contentMode: .fit) )
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12))
) )
.clipped() .clipped()
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)

View file

@ -23,6 +23,112 @@
import Combine import Combine
import Defaults import Defaults
import SwiftUI 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 // MARK: - Music Player Components
@ -43,8 +149,23 @@ struct MusicPlayerView: View {
struct AlbumArtView: View { struct AlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID 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 { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -58,24 +179,26 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) contentMode: .fill
.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
)
)
) )
.clipped() .clipped()
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 40) .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 { private var albumArtButton: some View {
@ -111,18 +234,10 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.overlay { .overlay {
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) 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
} }
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped() .clipped()

View file

@ -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: "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: "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: "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: "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: "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")), 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(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
@Default(.parallaxEffectIntensity) private var parallaxEffectIntensity @Default(.parallaxEffectIntensity) private var parallaxEffectIntensity
@ -2721,6 +2723,11 @@ struct Media: View {
.font(.caption) .font(.caption)
} }
} }
if mediaController == .spotify {
SpotifyAuthSettingsSection()
}
Section { Section {
Defaults.Toggle(key: .showStandardMediaControls) { Defaults.Toggle(key: .showStandardMediaControls) {
Text("Show media controls in Dynamic Island") Text("Show media controls in Dynamic Island")
@ -2819,6 +2826,11 @@ struct Media: View {
Text("Enable lyrics") Text("Enable lyrics")
} }
.settingsHighlight(id: highlightID("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 //Parallax Effect Intensity to control how much parallax is wanted
Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) {
@ -2907,6 +2919,27 @@ struct Media: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur")) .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: { } header: {
Text("Lock Screen Integration") Text("Lock Screen Integration")
} footer: { } footer: {
@ -4787,6 +4820,7 @@ struct LockScreenSettings: View {
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
@Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget
@ -5020,6 +5054,27 @@ struct LockScreenSettings: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur")) .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 { if !showStandardMediaControls {
Text("Enable Dynamic Island media controls to manage the lock screen panel.") Text("Enable Dynamic Island media controls to manage the lock screen panel.")

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -187,6 +187,7 @@ class LockScreenManager: ObservableObject {
// Hide panel window immediately and synchronously // Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
LockScreenPanelManager.shared.hidePanel() LockScreenPanelManager.shared.hidePanel()
FullScreenArtworkWindowManager.shared.hide()
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)

View file

@ -58,13 +58,30 @@ class LockScreenPanelManager {
return formatter.string(from: Date()) 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() { private func registerScreenChangeObservers() {
screenChangeObserver = NotificationCenter.default.addObserver( screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification, forName: NSApplication.didChangeScreenParametersNotification,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screen-parameters") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screen-parameters")
}
} }
let workspaceCenter = NSWorkspace.shared.notificationCenter let workspaceCenter = NSWorkspace.shared.notificationCenter
@ -73,7 +90,9 @@ class LockScreenPanelManager {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screens-did-wake") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
}
} }
workspaceObservers = [wakeObserver] workspaceObservers = [wakeObserver]
@ -94,8 +113,9 @@ class LockScreenPanelManager {
} }
let screenFrame = screen.frame let screenFrame = screen.frame
let targetFrame = collapsedFrame(for: screenFrame) let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
collapsedFrame = targetFrame let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize)
collapsedFrame = defaultCollapsedFrame
isPanelExpanded = false isPanelExpanded = false
currentAdditionalHeight = 0 currentAdditionalHeight = 0
@ -127,14 +147,14 @@ class LockScreenPanelManager {
} }
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame publishPanelFrame(targetFrame)
hideTask?.cancel() hideTask?.cancel()
panelAnimator.isPresented = false panelAnimator.isPresented = false
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size) hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
hosting.autoresizingMask = [.width, .height] hosting.autoresizingMask = [.width, .height]
window.contentView = hosting window.contentView = hosting
// Ensure the underlying window content is clipped to rounded corners // 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) { 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 return
} }
let resizeDuration: CFTimeInterval = 0.28 let resizeDuration: CFTimeInterval = 0.28
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width let targetFrame = targetFrame(
let targetHeight = baseSize.height + additionalHeight for: screen.frame,
let originX = baseFrame.midX - (targetWidth / 2) panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight)
let originY = baseFrame.origin.y )
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
if animated { if animated {
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
@ -185,7 +204,7 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
} }
latestFrame = targetFrame publishPanelFrame(targetFrame)
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
@ -229,7 +248,7 @@ class LockScreenPanelManager {
guard let window = panelWindow else { guard let window = panelWindow else {
print("LockScreenPanelManager: no panel to hide") print("LockScreenPanelManager: no panel to hide")
latestFrame = nil publishPanelFrame(nil)
return return
} }
@ -239,7 +258,7 @@ class LockScreenPanelManager {
await MainActor.run { await MainActor.run {
window?.orderOut(nil) window?.orderOut(nil)
window?.contentView = nil window?.contentView = nil
self.latestFrame = nil self.publishPanelFrame(nil)
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
} }
} }
@ -267,6 +286,74 @@ class LockScreenPanelManager {
.store(in: &cancellables) .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 { private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
let collapsedSize = LockScreenMusicPanel.collapsedSize let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2) let originX = screenFrame.midX - (collapsedSize.width / 2)
@ -289,3 +376,7 @@ class LockScreenPanelManager {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
} }
} }
extension Notification.Name {
static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange")
}

View file

@ -279,6 +279,11 @@ final class LockScreenTimerWidgetPanelManager {
originY = min(originY, topLimit) 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 let minY = screen.frame.minY + 100
originY = max(originY, minY) originY = max(originY, minY)

View file

@ -24,7 +24,11 @@ import Darwin
final class MemoryUsageMonitor { final class MemoryUsageMonitor {
static let shared = 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 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 pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact
private let restartCooldown: TimeInterval = 300 private let restartCooldown: TimeInterval = 300
private let logSampleInterval: TimeInterval = 300 private let logSampleInterval: TimeInterval = 300

View file

@ -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( let defaultImage: NSImage = .init(
systemSymbolName: "heart.fill", systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art" accessibilityDescription: "Album Art"
@ -475,10 +485,16 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position // Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>? private var lyricSyncTask: Task<Void, Never>?
private var lyricsFetchTask: Task<Void, Never>?
private var lyricsFetchKey: LyricsLookupKey?
private var activeLyricsKey: LyricsLookupKey?
private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:]
private var explicitLookupTask: Task<Void, Never>? private var explicitLookupTask: Task<Void, Never>?
private var explicitLookupKey: String? 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 liveStreamUnknownDurationCount: Int = 0
private var liveStreamEdgeObservationCount: Int = 0 private var liveStreamEdgeObservationCount: Int = 0
@ -512,6 +528,13 @@ class MusicManager: ObservableObject {
} }
.store(in: &cancellables) .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 // Observe Pear Desktop launch/terminate for auto-detection
setupPearDesktopAutoDetection() setupPearDesktopAutoDetection()
@ -580,6 +603,8 @@ class MusicManager: ObservableObject {
public func destroy() { public func destroy() {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
lyricsFetchTask?.cancel()
lyricSyncTask?.cancel()
explicitLookupTask?.cancel() explicitLookupTask?.cancel()
cancellables.removeAll() cancellables.removeAll()
controllerCancellables.removeAll() controllerCancellables.removeAll()
@ -701,6 +726,7 @@ class MusicManager: ObservableObject {
// Check for artwork changes // Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = let hasContentChange =
titleChanged titleChanged
|| artistChanged || artistChanged
@ -709,6 +735,11 @@ class MusicManager: ObservableObject {
|| bundleChanged || bundleChanged
|| contentIdentifierChanged || contentIdentifierChanged
|| contentURLChanged || contentURLChanged
let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL
if liveArtworkChanged {
self.videoArtworkURL = state.liveArtworkURL
}
// Handle artwork and visual transitions for changed content // Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
@ -735,10 +766,16 @@ class MusicManager: ObservableObject {
self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL self.lastArtworkContentURL = state.contentURL
// Fetch lyrics for new track whenever content changes self.prepareLyricsForCurrentTrack()
self.fetchLyrics() if let liveArtworkURL = state.liveArtworkURL {
self.videoArtworkURL = liveArtworkURL
} else {
self.fetchVideoArtwork()
}
self.refreshExplicitFlag(for: state) self.refreshExplicitFlag(for: state)
// Only update sneak peek if there's actual content and something changed // Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek() self.updateSneakPeek()
@ -1238,53 +1275,108 @@ class MusicManager: ObservableObject {
// MARK: - Lyrics Methods // MARK: - Lyrics Methods
func fetchLyrics() { func fetchLyrics() {
guard Defaults[.enableLyrics] else { return } prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics])
// If the lyrics panel is visible already, provide immediate feedback }
if showLyrics {
Task { @MainActor in private func handleLyricsPreferenceChange(isEnabled: Bool) {
self.currentLyrics = "Loading lyrics..." showLyrics = isEnabled
self.syncedLyrics = []
self.currentLyricIndex = -1 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 { let key = lookup.key
do { let lyricsEnabled = Defaults[.enableLyrics]
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
await MainActor.run { let trackChanged = activeLyricsKey != key
self.syncedLyrics = lyrics activeLyricsKey = key
self.currentLyricIndex = -1
if !lyrics.isEmpty {
self.currentLyrics = lyrics[0].text
} else {
self.currentLyrics = ""
}
// If lyrics are enabled, start syncing them to playback position if trackChanged {
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { syncedLyrics = []
self.startLyricSync() currentLyricIndex = -1
} else if self.syncedLyrics.isEmpty { currentLyrics = shouldShowLoading ? "Loading lyrics..." : ""
self.stopLyricSync() 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 { } catch {
print("Failed to fetch lyrics: \(error)") print("Failed to fetch lyrics: \(error)")
await MainActor.run { await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.syncedLyrics = [] self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1 self.currentLyricIndex = -1
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
self.stopLyricSync() 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 [] } guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode // Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.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), guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return [] return []
@ -1298,7 +1390,8 @@ class MusicManager: ObservableObject {
if let http = response as? HTTPURLResponse, http.statusCode == 200 { if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred) // Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], 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 plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] 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] { private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines) let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = [] var lyrics: [LyricLine] = []
@ -1424,6 +1618,32 @@ class MusicManager: ObservableObject {
lyricSyncTask = nil 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() { func toggleLyrics() {
// Toggle the UI state first so the views can react immediately. // Toggle the UI state first so the views can react immediately.
showLyrics.toggle() showLyrics.toggle()
@ -1518,7 +1738,6 @@ private struct AlbumArtFlipModifier: ViewModifier {
// Use a small tolerance to avoid flickering exactly at 90°/270°. // Use a small tolerance to avoid flickering exactly at 90°/270°.
if cos > 0.001 { return 1 } if cos > 0.001 { return 1 }
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 return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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[..<firstSemicolon])
}
value = value
.replacingOccurrences(of: "\"", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return value
}
private static func generateTOTPCode(from cipher: [Int]) throws -> 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<UInt64>.size)
let mac = HMAC<Insecure.SHA1>.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
}
}
}

View file

@ -17,12 +17,23 @@
*/ */
import Foundation import Foundation
import os
class SystemOSDManager { class SystemOSDManager {
private init() {} 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<Void, Never>?
var lastSuspendedPID: Int32 = -1
}
private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState())
/// Re-enables the system HUD by restarting OSDUIHelper /// Re-enables the system HUD by restarting OSDUIHelper
public static func enableSystemHUD() { public static func enableSystemHUD() {
stopSuppressionWatcher()
Task.detached(priority: .background) { Task.detached(priority: .background) {
await enableSystemHUDAsync() 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() { public static func disableSystemHUD() {
Task.detached(priority: .background) { Task.detached(priority: .background) {
await disableSystemHUDAsync() await disableSystemHUDAsync()
} }
startSuppressionWatcher()
} }
private static func disableSystemHUDAsync() async { private static func disableSystemHUDAsync() async {
@ -92,15 +106,35 @@ class SystemOSDManager {
kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"] kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"]
try kickstart.run() try kickstart.run()
kickstart.waitUntilExit() kickstart.waitUntilExit()
try await Task.sleep(nanoseconds: 500_000_000) // 500ms - async wait instead of usleep // launchctl kickstart returns once the request is queued, not after
// OSDUIHelper has actually forked. At cold boot the helper can take
let task = Process() // a while to appear — a fixed sleep races and the SIGSTOP misses,
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") // letting the native OSD render on the first volume/brightness key.
task.arguments = ["-STOP", "OSDUIHelper"] // Poll for the PID up to ~5s, then suspend, and retry if launchd
try task.run() // respawned a fresh copy between kickstart and SIGSTOP.
task.waitUntilExit() 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 { await MainActor.run {
print("✅ System HUD disabled") 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..<maxAttempts {
if isOSDUIHelperRunning() {
return true
}
try? await Task.sleep(nanoseconds: pollIntervalNanos)
}
return isOSDUIHelperRunning()
}
/// Background loop that catches OSDUIHelper respawns. macOS exits the
/// helper after a short idle period (JETSAM_REASON_MEMORY_IDLE_EXIT) and
/// launchd spins up a brand-new process on the next volume/brightness
/// keypress — that fresh PID renders the native OSD before any one-shot
/// SIGSTOP can hit it. Polling every 150ms is cheap (a single pgrep per
/// tick when nothing changed) and shrinks the visible-OSD window enough
/// to feel instant.
private static func startSuppressionWatcher() {
let newTask = Task.detached(priority: .background) {
while !Task.isCancelled {
let currentPID = osduiHelperPID()
let lastPID = suppressionState.withLock { $0.lastSuspendedPID }
if let pid = currentPID, pid != lastPID {
suspendOSDUIHelper()
suppressionState.withLock { $0.lastSuspendedPID = pid }
}
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
}
let previous = suppressionState.withLock { state -> Task<Void, Never>? in
let prior = state.task
state.task = newTask
return prior
}
previous?.cancel()
}
private static func stopSuppressionWatcher() {
let previous = suppressionState.withLock { state -> Task<Void, Never>? 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 /// Check if OSDUIHelper is currently running
public static func isOSDUIHelperRunning() -> Bool { public static func isOSDUIHelperRunning() -> Bool {
let task = Process() let task = Process()

View file

@ -926,6 +926,10 @@ extension Defaults.Keys {
) )
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true)
static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key<Bool>("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false)
static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true)
static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key<Bool>("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center") static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
@ -1014,6 +1018,10 @@ extension Defaults.Keys {
// MARK: Media Controller // MARK: Media Controller
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController) static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
static let spotifySPDCCookie = Key<String>("spotifySPDCCookie", default: "")
static let spotifyAuthAccessToken = Key<String>("spotifyAuthAccessToken", default: "")
static let spotifyAuthAccessTokenExpiration = Key<Double>("spotifyAuthAccessTokenExpiration", default: 0)
static let spotifyAuthLastValidatedAt = Key<Double>("spotifyAuthLastValidatedAt", default: 0)
// MARK: Bluetooth Audio Devices // MARK: Bluetooth Audio Devices
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true) static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
@ -1216,6 +1224,7 @@ extension Defaults.Keys {
// MARK: Lyrics Feature // MARK: Lyrics Feature
static let enableLyrics = Key<Bool>("enableLyrics", default: false) static let enableLyrics = Key<Bool>("enableLyrics", default: false)
static let showLiveCanvasInDynamicIsland = Key<Bool>("showLiveCanvasInDynamicIsland", default: false)
// MARK: Notes Feature // MARK: Notes Feature
static let enableNotes = Key<Bool>("enableNotes", default: false) static let enableNotes = Key<Bool>("enableNotes", default: false)

View file

@ -44,6 +44,7 @@ struct PlaybackState {
var repeatMode: RepeatMode = .off var repeatMode: RepeatMode = .off
var lastUpdated: Date = Date.distantPast var lastUpdated: Date = Date.distantPast
var artwork: Data? var artwork: Data?
var liveArtworkURL: URL?
} }
extension PlaybackState: Equatable { extension PlaybackState: Equatable {
@ -61,5 +62,6 @@ extension PlaybackState: Equatable {
&& lhs.isShuffled == rhs.isShuffled && lhs.isShuffled == rhs.isShuffled
&& lhs.repeatMode == rhs.repeatMode && lhs.repeatMode == rhs.repeatMode
&& lhs.artwork == rhs.artwork && lhs.artwork == rhs.artwork
&& lhs.liveArtworkURL == rhs.liveArtworkURL
} }
} }