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

View file

@ -7,6 +7,8 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppleMusicUsageDescription</key>
<string>Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper.</string>
<key>NSAudioCaptureUsageDescription</key>
<string>Atoll needs audio capture permission to display the real-time music waveform visualizer.</string>
<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" : {
"localizations" : {
"ko" : {
@ -2513,6 +2517,10 @@
}
}
},
"2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com" : {
"comment" : "A step to copy the Spotify cookie from the developer tools.",
"isCommentAutoGenerated" : true
},
"3 items" : {
"comment" : "merge:ko:alt='항목 3개'",
"localizations" : {
@ -2548,6 +2556,10 @@
}
}
},
"3. Copy the value of `sp_dc` and paste it here" : {
"comment" : "A step to paste the Spotify cookie into the app.",
"isCommentAutoGenerated" : true
},
"5 colors" : {
"comment" : "merge:ko:alt='5가지 색상'",
"localizations" : {
@ -6389,6 +6401,10 @@
}
}
},
"Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track." : {
"comment" : "A description of how Spotify cookies are used by Atoll.",
"isCommentAutoGenerated" : true
},
"Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : {
"comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.",
"isCommentAutoGenerated" : true,
@ -7074,6 +7090,9 @@
}
}
},
"Battery at %d percent, charging on hold" : {
"comment" : "Plugged in but charging is paused by the system"
},
"Battery charging" : {
"comment" : "Battery charging without estimate",
"localizations" : {
@ -9043,6 +9062,9 @@
},
"Charging HUD" : {
},
"Charging on hold" : {
"comment" : "Plugged in but charging is paused by the system"
},
"Charging on Hold: Desktop Mode" : {
"localizations" : {
@ -9963,7 +9985,6 @@
}
},
"Clear" : {
"extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@ -11679,6 +11700,10 @@
}
}
},
"Connect Spotify" : {
"comment" : "A heading for a Spotify login sheet.",
"isCommentAutoGenerated" : true
},
"Connected" : {
"extractionState" : "stale",
"localizations" : {
@ -19833,6 +19858,9 @@
}
}
}
},
"Fullscreen artwork on right-click" : {
},
"Fully charged" : {
"comment" : "Charging fallback label when already charged",
@ -20330,6 +20358,10 @@
}
}
},
"Get the cookie manually" : {
"comment" : "A section that provides instructions on how to get the Spotify cookie manually.",
"isCommentAutoGenerated" : true
},
"Get your free API key from Google AI Studio" : {
"localizations" : {
"fr" : {
@ -22869,6 +22901,9 @@
}
}
}
},
"Keep album art visible during fullscreen artwork" : {
},
"Keep terminal open until clicked outside" : {
"localizations" : {
@ -26325,6 +26360,10 @@
}
}
},
"Method source" : {
"comment" : "A link to the source code of Spotify's method for requesting a Spotify web player token.",
"isCommentAutoGenerated" : true
},
"Mic" : {
"comment" : "Name of a sneak action type that controls the microphone.",
"isCommentAutoGenerated" : true,
@ -30186,6 +30225,9 @@
}
}
},
"On hold" : {
"comment" : "Plugged in but charging is paused by the system"
},
"One or more files failed to load" : {
"localizations" : {
"ar" : {
@ -30402,6 +30444,10 @@
}
}
},
"Open in Browser" : {
"comment" : "A button that opens Spotify in the user's browser.",
"isCommentAutoGenerated" : true
},
"Open in Calendar" : {
"comment" : "A button label that opens a calendar with the event details.",
"isCommentAutoGenerated" : true,
@ -30490,6 +30536,10 @@
}
}
},
"Open Spotify Web Player" : {
"comment" : "A link to open Spotify.",
"isCommentAutoGenerated" : true
},
"Open System Settings" : {
"localizations" : {
"ko" : {
@ -30844,6 +30894,10 @@
}
}
},
"Paste from Clipboard" : {
"comment" : "A button that copies the `sp_dc` cookie from the clipboard.",
"isCommentAutoGenerated" : true
},
"Pause" : {
"localizations" : {
"ru" : {
@ -33306,6 +33360,9 @@
}
}
}
},
"Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect." : {
},
"Request Access" : {
"localizations" : {
@ -33653,6 +33710,10 @@
}
}
},
"Reset Session" : {
"comment" : "A button that clears the Spotify login session.",
"isCommentAutoGenerated" : true
},
"Reset Timer" : {
"localizations" : {
"ko" : {
@ -34283,6 +34344,9 @@
}
}
}
},
"Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas." : {
},
"Ring" : {
"comment" : "The name of the timer progress style that shows a ring shape.",
@ -37345,6 +37409,9 @@
}
}
}
},
"Show live canvas in Dynamic Island" : {
},
"Show location label" : {
"localizations" : {
@ -38382,6 +38449,14 @@
}
}
},
"Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below." : {
"comment" : "A description of how to sign in to Spotify to get the `sp_dc` cookie.",
"isCommentAutoGenerated" : true
},
"Sign in with Spotify" : {
"comment" : "A button that opens a sheet for signing in with Spotify.",
"isCommentAutoGenerated" : true
},
"Simple and minimal interface with just the essentials for everyday tasks." : {
"localizations" : {
"ko" : {
@ -39085,6 +39160,10 @@
}
}
},
"sp_dc cookie" : {
"comment" : "A label for the Spotify `sp_dc` cookie.",
"isCommentAutoGenerated" : true
},
"Speed" : {
"localizations" : {
"ar" : {
@ -39238,6 +39317,14 @@
}
}
},
"Spotify Canvas Session" : {
"comment" : "A section header for the Spotify Canvas session settings.",
"isCommentAutoGenerated" : true
},
"Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings." : {
"comment" : "A help message for the Spotify login sheet.",
"isCommentAutoGenerated" : true
},
"Square" : {
"localizations" : {
"ar" : {
@ -43416,6 +43503,9 @@
}
}
}
},
"Use album art layout over fullscreen canvas" : {
},
"Use circular battery indicator" : {
"localizations" : {
@ -43955,6 +44045,14 @@
},
"shouldTranslate" : false
},
"Validate Cookie" : {
"comment" : "A button that validates a Spotify session cookie.",
"isCommentAutoGenerated" : true
},
"Validating..." : {
"comment" : "The text of a button that is shown while Spotify's session is being validated.",
"isCommentAutoGenerated" : true
},
"Variant %lld" : {
"comment" : "A label for a specific variant of the Liquid Glass.",
"isCommentAutoGenerated" : true,

View file

@ -20,8 +20,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import Combine
import Foundation
import SwiftUI
class SpotifyController: MediaControllerProtocol {
@ -31,91 +31,117 @@ class SpotifyController: MediaControllerProtocol {
@Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: SpotifyController.bundleIdentifier
)
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // Spotify controller always works
true
}
private var notificationTask: Task<Void, Never>?
private var sessionChangeCancellable: AnyCancellable?
// Constant for time between command and update
private let commandUpdateDelay: Duration = .milliseconds(25)
private var lastArtworkURL: String?
private var artworkFetchTask: Task<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() {
setupPlaybackStateChangeObserver()
setupSessionChangeObserver()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
private func setupSessionChangeObserver() {
sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange)
.sink { [weak self] _ in
self?.lastCanvasRequestTrackURI = nil
self?.lastCanvasRequestDate = .distantPast
if let self {
var updatedState = self.playbackState
updatedState.liveArtworkURL = nil
self.playbackState = updatedState
}
Task {
await self?.updatePlaybackInfo()
}
}
}
deinit {
notificationTask?.cancel()
artworkFetchTask?.cancel()
canvasFetchTask?.cancel()
sessionChangeCancellable?.cancel()
}
// MARK: - Protocol Implementation
func play() async { await executeCommand("play") }
func pause() async { await executeCommand("pause") }
func togglePlay() async { await executeCommand("playpause") }
func nextTrack() async { await executeCommand("next track") }
func previousTrack() async {
await executeAndRefresh("previous track")
}
func seek(to time: Double) async {
await executeAndRefresh("set player position to \(time)")
}
func toggleShuffle() async {
await executeAndRefresh("set shuffling to not shuffling")
}
func toggleRepeat() async {
await executeAndRefresh("set repeating to not repeating")
}
func isActive() -> Bool {
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 11 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? ""
let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? ""
let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString)
var state = PlaybackState(
bundleIdentifier: Self.bundleIdentifier,
isPlaying: isPlaying,
@ -129,20 +155,40 @@ class SpotifyController: MediaControllerProtocol {
repeatMode: isRepeating ? .all : .off,
lastUpdated: Date()
)
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL
state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines)
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork
}
playbackState = state
let isSameCanvasTrack = trackURI == currentCanvasTrackURI
if isSameCanvasTrack {
state.liveArtworkURL = playbackState.liveArtworkURL
} else {
state.liveArtworkURL = nil
currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI
canvasFetchTask?.cancel()
canvasFetchTask = nil
}
playbackState = state
if !trackURI.isEmpty {
scheduleCanvasFetchIfNeeded(for: trackURI)
}
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
artworkFetchTask?.cancel()
let currentState = state
let expectedTrackURI = trackURI
artworkFetchTask = Task {
do {
@ -150,8 +196,13 @@ class SpotifyController: MediaControllerProtocol {
await MainActor.run { [weak self] in
guard let self = self else { return }
guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else {
self.artworkFetchTask = nil
return
}
var updatedState = currentState
updatedState.artwork = data
updatedState.liveArtworkURL = self.playbackState.liveArtworkURL
self.playbackState = updatedState
self.lastArtworkURL = artworkURL
self.artworkFetchTask = nil
@ -164,9 +215,40 @@ class SpotifyController: MediaControllerProtocol {
}
}
}
// MARK: - Private Methods
private func scheduleCanvasFetchIfNeeded(for trackURI: String) {
guard !trackURI.isEmpty else { return }
guard playbackState.liveArtworkURL == nil else { return }
guard canvasFetchTask == nil else { return }
if lastCanvasRequestTrackURI == trackURI,
Date().timeIntervalSince(lastCanvasRequestDate) < 5
{
return
}
lastCanvasRequestTrackURI = trackURI
lastCanvasRequestDate = Date()
canvasFetchTask = Task { [weak self] in
guard let self else { return }
let canvasURL = await self.canvasResolver.canvasURL(for: trackURI)
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.currentCanvasTrackURI == trackURI else {
self.canvasFetchTask = nil
return
}
var updatedState = self.playbackState
updatedState.liveArtworkURL = canvasURL
self.playbackState = updatedState
self.canvasFetchTask = nil
}
}
}
private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)"
try? await AppleScriptHelper.executeVoid(script)
@ -177,7 +259,7 @@ class SpotifyController: MediaControllerProtocol {
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Spotify"
@ -192,21 +274,294 @@ class SpotifyController: MediaControllerProtocol {
set shuffleState to shuffling
set repeatState to repeating
set artworkURL to artwork url of current track
set trackIdentifier to ""
try
set trackIdentifier to id of current track
end try
set trackSpotifyURL to ""
try
set trackSpotifyURL to spotify url of current track
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL}
set trackID to id of current track
set spotifyURL to spotify url of current track
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL}
on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
end try
end tell
"""
return try await AppleScriptHelper.execute(script)
}
private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String {
let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedIdentifier.hasPrefix("spotify:track:") {
return trimmedIdentifier
}
if isLikelySpotifyTrackID(trimmedIdentifier) {
return "spotify:track:\(trimmedIdentifier)"
}
if let trackID = extractTrackID(from: spotifyURLString) {
return "spotify:track:\(trackID)"
}
return ""
}
private static func extractTrackID(from spotifyURLString: String) -> String? {
guard let url = URL(string: spotifyURLString),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return nil }
let pathComponents = components.path.split(separator: "/")
guard let trackIndex = pathComponents.firstIndex(of: "track"),
trackIndex + 1 < pathComponents.count
else { return nil }
let identifier = String(pathComponents[trackIndex + 1])
return isLikelySpotifyTrackID(identifier) ? identifier : nil
}
private static func isLikelySpotifyTrackID(_ value: String) -> Bool {
value.count == 22 && value.unicodeScalars.allSatisfy { scalar in
CharacterSet.alphanumerics.contains(scalar)
}
}
}
private actor SpotifyCanvasResolver {
private struct CacheEntry {
let url: URL?
let expiresAt: Date
}
private var cache: [String: CacheEntry] = [:]
func canvasURL(for trackURI: String) async -> URL? {
let now = Date()
if let entry = cache[trackURI], entry.expiresAt > now {
return entry.url
}
let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true)
let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600
cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime))
return resolvedURL
}
private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? {
guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else {
return nil
}
var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!)
request.httpMethod = "POST"
request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI)
request.setValue("application/protobuf", forHTTPHeaderField: "Accept")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("en", forHTTPHeaderField: "Accept-Language")
request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return nil
}
if httpResponse.statusCode == 401, retryOnUnauthorized {
await SpotifyAuthManager.shared.invalidateAccessToken()
return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false)
}
guard (200..<300).contains(httpResponse.statusCode) else {
return nil
}
return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI)
} catch {
return nil
}
}
}
private enum SpotifyCanvasProtobuf {
static func makeCanvasRequest(trackURI: String) -> Data {
let uriData = Data(trackURI.utf8)
var trackMessage = Data()
trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2))
trackMessage.append(varint(for: uriData.count))
trackMessage.append(uriData)
var request = Data()
request.append(protobufKey(fieldNumber: 1, wireType: 2))
request.append(varint(for: trackMessage.count))
request.append(trackMessage)
return request
}
static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? {
do {
var reader = ProtobufReader(data: data)
var matchingURLs: [URL] = []
while !reader.isAtEnd {
let key = try reader.readVarint()
let fieldNumber = Int(key >> 3)
let wireType = Int(key & 0x7)
switch (fieldNumber, wireType) {
case (1, 2):
let canvasData = try reader.readLengthDelimited()
if let record = try parseCanvasRecord(from: canvasData),
record.trackURI == trackURI,
let canvasURL = record.canvasURL
{
matchingURLs.append(canvasURL)
}
default:
try reader.skipField(wireType: wireType)
}
}
return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first
} catch {
return nil
}
}
private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? {
var reader = ProtobufReader(data: data)
var trackURI = ""
var canvasURL: URL?
while !reader.isAtEnd {
let key = try reader.readVarint()
let fieldNumber = Int(key >> 3)
let wireType = Int(key & 0x7)
switch (fieldNumber, wireType) {
case (2, 2):
let urlString = try reader.readString()
canvasURL = URL(string: urlString)
case (5, 2):
trackURI = try reader.readString()
default:
try reader.skipField(wireType: wireType)
}
}
guard !trackURI.isEmpty else { return nil }
return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL)
}
private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data {
var key = Data()
key.append(varint(for: (fieldNumber << 3) | wireType))
return key
}
private static func varint(for value: Int) -> Data {
var remaining = UInt64(value)
var buffer = Data()
repeat {
var nextByte = UInt8(remaining & 0x7F)
remaining >>= 7
if remaining != 0 {
nextByte |= 0x80
}
buffer.append(nextByte)
} while remaining != 0
return buffer
}
}
private struct SpotifyCanvasRecord {
let trackURI: String
let canvasURL: URL?
}
private struct ProtobufReader {
enum ReaderError: Error {
case malformedVarint
case unexpectedEnd
case unsupportedWireType
}
private let data: Data
private var index: Data.Index
init(data: Data) {
self.data = data
self.index = data.startIndex
}
var isAtEnd: Bool {
index >= data.endIndex
}
mutating func readVarint() throws -> UInt64 {
var result: UInt64 = 0
var shift: UInt64 = 0
while shift < 64 {
guard index < data.endIndex else {
throw ReaderError.unexpectedEnd
}
let byte = data[index]
index = data.index(after: index)
result |= UInt64(byte & 0x7F) << shift
if (byte & 0x80) == 0 {
return result
}
shift += 7
}
throw ReaderError.malformedVarint
}
mutating func readLengthDelimited() throws -> Data {
let length = Int(try readVarint())
guard length >= 0 else {
throw ReaderError.unexpectedEnd
}
let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex)
guard let endIndex else {
throw ReaderError.unexpectedEnd
}
let slice = data[index..<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 private var routeManager = AudioRouteManager.shared
@ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator
@ -46,6 +47,7 @@ struct LockScreenMusicPanel: View {
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false
@State private var isArtworkFullscreen = false
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@ -66,6 +68,9 @@ struct LockScreenMusicPanel: View {
@Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
@Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
init(animator: LockScreenPanelAnimator) {
_animator = ObservedObject(wrappedValue: animator)
@ -118,6 +123,22 @@ struct LockScreenMusicPanel: View {
private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder
}
private var hidesInlineArtworkForSpotifyCanvasFallback: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var usesSpotifyCanvasFallbackContentPresentation: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var showsDetachedFullscreenLyrics: Bool {
usesSpotifyCanvasFallbackContentPresentation && enableLyrics
}
private var shouldShowInlineLyrics: Bool {
enableLyrics && !showsDetachedFullscreenLyrics
}
var body: some View {
if isActive && musicManager.hasActiveSession {
@ -132,16 +153,27 @@ struct LockScreenMusicPanel: View {
ZStack(alignment: .topLeading) {
panelBackgroundLayer
panelForeground
.shadow(
color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.55) : .clear,
radius: usesSpotifyCanvasFallbackContentPresentation ? 10 : 0,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 2 : 0
)
}
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay {
if showPanelBorder {
if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation {
panelBorderOverlay
}
}
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.shadow(
color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.35) : Color.black.opacity(0.3),
radius: usesSpotifyCanvasFallbackContentPresentation ? 26 : 20,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 14 : 10
)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear {
@ -163,6 +195,11 @@ struct LockScreenMusicPanel: View {
parallaxResumeWorkItem = nil
isParallaxSuspended = false
}
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
}
.onChange(of: isExpanded) { _, expanded in
updatePanelSize()
}
@ -222,9 +259,13 @@ struct LockScreenMusicPanel: View {
collapsedLayout
}
}
.padding(.horizontal, isExpanded ? 24 : 20)
.padding(.vertical, isExpanded ? 22 : 16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20))
.padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16))
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading
)
}
private var collapsedLayout: some View {
@ -239,25 +280,40 @@ struct LockScreenMusicPanel: View {
}
private var expandedLayout: some View {
HStack(alignment: .center, spacing: expandedContentSpacing) {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
.frame(width: 230, height: 230)
Group {
if hidesInlineArtworkForSpotifyCanvasFallback {
VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(alignment: .center, spacing: expandedContentSpacing) {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
.frame(width: 230, height: 230)
VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: .infinity, alignment: .center)
}
private var collapsedHeader: some View {
HStack(alignment: .center, spacing: 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) {
if !hidesInlineArtworkForSpotifyCanvasFallback {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
}
GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) {
@ -329,30 +385,48 @@ struct LockScreenMusicPanel: View {
}
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
Button(action: toggleExpanded) {
ZStack(alignment: .bottomTrailing) {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
.offset(x: appIconOffset, y: appIconOffset)
.transition(.scale.combined(with: .opacity))
}
return ZStack(alignment: .bottomTrailing) {
if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork {
RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)
.fill(Color.white.opacity(0.06))
.overlay(
Image(systemName: "photo.fill")
.font(.system(size: size * 0.3, weight: .light))
.foregroundColor(.white.opacity(0.2))
)
} else {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
.offset(x: appIconOffset, y: appIconOffset)
.transition(.scale.combined(with: .opacity))
}
.albumArtFlip(angle: musicManager.flipAngle)
.parallax3D(enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended)
.frame(width: size)
.background(albumArtBackground(cornerRadius: cornerRadius))
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
}
}
.buttonStyle(PlainButtonStyle())
.albumArtFlip(angle: isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle)
.parallax3D(
enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork),
suspended: isParallaxSuspended
)
.frame(width: size)
.background(albumArtBackground(cornerRadius: artworkCornerRadius))
.clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous))
.opacity(musicManager.isPlaying ? 1 : 0.4)
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
.onTapGesture {
toggleExpanded()
}
.onRightClick {
expandArtworkToFullscreen()
}
}
@ViewBuilder
@ -384,6 +458,41 @@ struct LockScreenMusicPanel: View {
}
}
private func expandArtworkToFullscreen() {
guard fullscreenArtworkEnabled else { return }
if FullScreenArtworkWindowManager.shared.isShowing {
FullScreenArtworkWindowManager.shared.hide()
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
return
}
guard musicManager.hasActiveSession else { return }
let artwork = musicManager.albumArt
let videoURL = musicManager.videoArtworkURL
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = true
}
FullScreenArtworkWindowManager.shared.onDismiss = {
Task { @MainActor in
withAnimation(.easeInOut(duration: 0.28)) {
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
}
}
}
FullScreenArtworkWindowManager.shared.show(
artwork: artwork,
videoURL: fullscreenVideoArtwork ? videoURL : nil,
allowLiveWallpaper: fullscreenVideoArtwork
)
}
private func registerInteraction() {
cancelCollapseTimer()
guard isExpanded else { return }
@ -484,7 +593,7 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10
return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing)
@ -505,7 +614,7 @@ struct LockScreenMusicPanel: View {
}
}
if enableLyrics {
if shouldShowInlineLyrics {
lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity))
}
@ -949,7 +1058,7 @@ struct LockScreenMusicPanel: View {
}
private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics)
}
private var accessorySectionExtraHeight: CGFloat {
@ -979,7 +1088,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics)
}
private func updatePanelSize(animated: Bool = true) {
@ -1034,7 +1143,9 @@ struct LockScreenMusicPanel: View {
@ViewBuilder
private var panelBackgroundLayer: some View {
if usesCustomLiquidGlass {
if usesSpotifyCanvasFallbackContentPresentation {
canvasFallbackScrim
} else if usesCustomLiquidGlass {
customLiquidPanelBackdrop
} else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop
@ -1045,6 +1156,31 @@ struct LockScreenMusicPanel: View {
}
}
private var canvasFallbackScrim: some View {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
.overlay {
LinearGradient(
colors: [
Color.black.opacity(0.32),
Color.black.opacity(0.18),
Color.black.opacity(0.32)
],
startPoint: .top,
endPoint: .bottom
)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
}
.overlay {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 0.6)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
@ViewBuilder
private var standardLiquidPanelBackdrop: some View {
if #available(macOS 26.0, *) {
@ -1132,7 +1268,8 @@ struct LockScreenMusicPanel: View {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? cornerRadius/3 : cornerRadius))
.compositingGroup()
.clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous))
}
@ViewBuilder
@ -1170,6 +1307,13 @@ struct LockScreenMusicPanel: View {
isExpanded ? 18 : 12
}
private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat {
let aspectRatio = musicManager.albumArt.size.height > 0
? musicManager.albumArt.size.width / musicManager.albumArt.size.height
: 1
return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius
}
@available(macOS 26.0, *)
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@ -1384,3 +1528,87 @@ private struct GlassTextBackdrop: View {
.accessibilityHidden(true)
}
}
// MARK: - Right Click Gesture
private struct RightClickOverlay: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content.overlay(
RightClickReceiverView(action: action)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
private struct RightClickReceiverView: NSViewRepresentable {
let action: () -> Void
func makeNSView(context: Context) -> RightClickNSView {
let view = RightClickNSView()
view.onRightClick = action
return view
}
func updateNSView(_ nsView: RightClickNSView, context: Context) {
nsView.onRightClick = action
}
}
private extension View {
func onRightClick(perform action: @escaping () -> Void) -> some View {
modifier(RightClickOverlay(action: action))
}
}
final class RightClickNSView: NSView {
var onRightClick: (() -> Void)?
private var eventMonitor: Any?
deinit {
removeEventMonitor()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
removeEventMonitor()
} else {
installEventMonitorIfNeeded()
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in
guard let self, let window = self.window, event.window === window else {
return event
}
let pointInView = self.convert(event.locationInWindow, from: nil)
guard self.bounds.contains(pointInView) else {
return event
}
self.onRightClick?()
return nil
}
}
private func removeEventMonitor() {
guard let eventMonitor else { return }
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
}
}
extension Notification.Name {
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
}

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 {
abs(timerManager.remainingTime) >= 3600
}
@ -57,12 +53,6 @@ struct LockScreenTimerWidget: View {
abs(timerManager.remainingTime) >= 36_000 // 10 hours or more
}
private var titleFrameWidth: CGFloat {
if hasDoubleDigitHours { return 60 }
if hasHoursComponent { return 80 }
return 110
}
private var countdownFrameWidth: CGFloat {
if hasDoubleDigitHours { return 200 }
if hasHoursComponent { return 185 }
@ -74,10 +64,6 @@ struct LockScreenTimerWidget: View {
return .system(size: baseSize, weight: .bold, design: .rounded)
}
private var timerLabel: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
}
private var countdownText: String {
timerManager.formattedRemainingTime()
}
@ -195,8 +181,7 @@ struct LockScreenTimerWidget: View {
controlButtons
.padding(.trailing, 12)
titleSection
.frame(maxWidth: .infinity)
Spacer()
countdownSection
.padding(.leading, 12)
@ -261,19 +246,6 @@ struct LockScreenTimerWidget: View {
.layoutPriority(2)
}
private var titleSection: some View {
MarqueeText(
.constant(timerLabel),
font: displayFont(size: 15),
nsFont: .title3,
textColor: accentColor,
minDuration: 0.16,
frameWidth: titleFrameWidth
)
.frame(maxWidth: .infinity, alignment: .center)
.layoutPriority(0)
}
private var accentRibbon: some View {
Capsule()
.fill(accentGradient)

View file

@ -1261,7 +1261,15 @@ struct LockScreenWeatherWidget: View {
private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if info.isPluggedIn && !info.isCharging {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
let level = info.batteryLevel.map(clampedBatteryLevel)
if level == 100 {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
}
if let level {
let onHold = NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system")
return "\(level)% • \(onHold)"
}
return NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system")
}
return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
}
@ -1456,7 +1464,17 @@ struct LockScreenWeatherWidget: View {
}
if charging.isPluggedIn && !charging.isCharging {
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
let level = charging.batteryLevel.map(clampedBatteryLevel)
if level == 100 {
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
}
if let level {
return String(
format: NSLocalizedString("Battery at %d percent, charging on hold", comment: "Plugged in but charging is paused by the system"),
level
)
}
return NSLocalizedString("Charging on hold", comment: "Plugged in but charging is paused by the system")
}
if snapshot.showsChargingPercentage, let level = charging.batteryLevel {

View file

@ -1113,8 +1113,17 @@ private struct MinimalisticReminderDetailsView: View {
struct MinimalisticAlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 ? 4 : 12
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] {
@ -1128,16 +1137,27 @@ struct MinimalisticAlbumArtView: View {
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.scaledToFill()
DynamicIslandArtworkSourceView(
cornerRadius: albumArtCornerRadius,
contentMode: .fill
)
)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius))
.scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92))
.blur(radius: 35)
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3)))
.opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.68 : 0.22)
: min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.28 : 0.16),
radius: usesLiveCanvasArtwork ? 18 : 12,
x: 0,
y: 0
)
}
private var albumArtButton: some View {
@ -1147,13 +1167,10 @@ struct MinimalisticAlbumArtView: View {
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12))
DynamicIslandArtworkSourceView(
cornerRadius: albumArtCornerRadius,
contentMode: .fit
)
)
.clipped()
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)

View file

@ -23,6 +23,112 @@
import Combine
import Defaults
import SwiftUI
import AppKit
import AVFoundation
private final class DynamicIslandArtworkLoopController {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
init(url: URL) {
let item = AVPlayerItem(url: url)
player = AVQueuePlayer()
player.isMuted = true
player.actionAtItemEnd = .none
looper = AVPlayerLooper(player: player, templateItem: item)
player.play()
}
deinit {
player.pause()
looper = nil
}
}
private final class DynamicIslandArtworkVideoContainerView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
playerLayer.videoGravity = .resizeAspectFill
playerLayer.backgroundColor = NSColor.clear.cgColor
layer?.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
nil
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
private struct DynamicIslandArtworkVideoView: NSViewRepresentable {
let url: URL
let videoGravity: AVLayerVideoGravity
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> DynamicIslandArtworkVideoContainerView {
let view = DynamicIslandArtworkVideoContainerView(frame: .zero)
context.coordinator.attach(layer: view.playerLayer, url: url, gravity: videoGravity)
return view
}
func updateNSView(_ nsView: DynamicIslandArtworkVideoContainerView, context: Context) {
context.coordinator.attach(layer: nsView.playerLayer, url: url, gravity: videoGravity)
}
final class Coordinator {
private var controller: DynamicIslandArtworkLoopController?
private var currentURL: URL?
func attach(layer: AVPlayerLayer, url: URL, gravity: AVLayerVideoGravity) {
layer.videoGravity = gravity
if currentURL != url || controller == nil {
currentURL = url
controller = DynamicIslandArtworkLoopController(url: url)
}
if layer.player !== controller?.player {
layer.player = controller?.player
}
}
}
}
struct DynamicIslandArtworkSourceView: View {
@ObservedObject private var musicManager = MusicManager.shared
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let cornerRadius: CGFloat
let contentMode: ContentMode
private var liveCanvasURL: URL? {
guard showLiveCanvasInDynamicIsland else { return nil }
return musicManager.videoArtworkURL
}
var body: some View {
Group {
if let liveCanvasURL {
DynamicIslandArtworkVideoView(url: liveCanvasURL, videoGravity: .resizeAspectFill)
} else {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: contentMode)
}
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
// MARK: - Music Player Components
@ -43,8 +149,23 @@ struct MusicPlayerView: View {
struct AlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0
? MusicPlayerImageSizes.cornerRadiusInset.opened / 3
: MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0
? MusicPlayerImageSizes.cornerRadiusInset.closed / 3
: MusicPlayerImageSizes.cornerRadiusInset.closed
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] {
@ -58,24 +179,26 @@ struct AlbumArtView: View {
Color.clear
.aspectRatio(1, contentMode: .fit)
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
)
DynamicIslandArtworkSourceView(
cornerRadius: albumArtCornerRadius,
contentMode: .fill
)
)
.clipped()
.scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92))
.blur(radius: 40)
.opacity(musicManager.isPlaying ? 0.5 : 0)
.opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.62 : 0.18)
: (musicManager.isPlaying ? 0.5 : 0)
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.24 : 0.16),
radius: usesLiveCanvasArtwork ? 22 : 14,
x: 0,
y: 0
)
}
private var albumArtButton: some View {
@ -111,18 +234,10 @@ struct AlbumArtView: View {
Color.clear
.aspectRatio(1, contentMode: .fit)
.overlay {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
) // ensures that even the rectagular album cover is rounded just like the MacOS media player
DynamicIslandArtworkSourceView(
cornerRadius: albumArtCornerRadius,
contentMode: .fit
)
}
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped()

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: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")),
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")),
SettingsSearchEntry(tab: .media, title: "Show live canvas in Dynamic Island", keywords: ["canvas", "live canvas", "album art", "dynamic island", "spotify canvas"], highlightID: SettingsTab.media.highlightID(for: "Show live canvas in Dynamic Island")),
SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")),
SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")),
SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")),
@ -2666,6 +2667,7 @@ struct Media: View {
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
@Default(.parallaxEffectIntensity) private var parallaxEffectIntensity
@ -2721,6 +2723,11 @@ struct Media: View {
.font(.caption)
}
}
if mediaController == .spotify {
SpotifyAuthSettingsSection()
}
Section {
Defaults.Toggle(key: .showStandardMediaControls) {
Text("Show media controls in Dynamic Island")
@ -2819,6 +2826,11 @@ struct Media: View {
Text("Enable lyrics")
}
.settingsHighlight(id: highlightID("Enable lyrics"))
Defaults.Toggle(key: .showLiveCanvasInDynamicIsland) {
Text("Show live canvas in Dynamic Island")
}
.settingsHighlight(id: highlightID("Show live canvas in Dynamic Island"))
.help("Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect.")
//Parallax Effect Intensity to control how much parallax is wanted
Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) {
@ -2907,6 +2919,27 @@ struct Media: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur"))
}
VStack(alignment: .leading, spacing: 4) {
Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) {
Text("Fullscreen artwork on right-click")
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} header: {
Text("Lock Screen Integration")
} footer: {
@ -4787,6 +4820,7 @@ struct LockScreenSettings: View {
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
@Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget
@ -5020,6 +5054,27 @@ struct LockScreenSettings: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur"))
}
VStack(alignment: .leading, spacing: 4) {
Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) {
Text("Fullscreen artwork on right-click")
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if !showStandardMediaControls {
Text("Enable Dynamic Island media controls to manage the lock screen panel.")

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
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
LockScreenPanelManager.shared.hidePanel()
FullScreenArtworkWindowManager.shared.hide()
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)

View file

@ -58,13 +58,30 @@ class LockScreenPanelManager {
return formatter.string(from: Date())
}
private func publishPanelFrame(_ frame: NSRect?) {
latestFrame = frame
var userInfo: [AnyHashable: Any] = [:]
if let frame {
userInfo["frame"] = NSValue(rect: frame)
}
NotificationCenter.default.post(
name: .atollLockScreenPanelFrameDidChange,
object: self,
userInfo: userInfo
)
}
private func registerScreenChangeObservers() {
screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screen-parameters")
Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screen-parameters")
}
}
let workspaceCenter = NSWorkspace.shared.notificationCenter
@ -73,7 +90,9 @@ class LockScreenPanelManager {
object: nil,
queue: .main
) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
}
}
workspaceObservers = [wakeObserver]
@ -94,8 +113,9 @@ class LockScreenPanelManager {
}
let screenFrame = screen.frame
let targetFrame = collapsedFrame(for: screenFrame)
collapsedFrame = targetFrame
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize)
collapsedFrame = defaultCollapsedFrame
isPanelExpanded = false
currentAdditionalHeight = 0
@ -127,14 +147,14 @@ class LockScreenPanelManager {
}
window.setFrame(targetFrame, display: true)
latestFrame = targetFrame
publishPanelFrame(targetFrame)
hideTask?.cancel()
panelAnimator.isPresented = false
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
hosting.autoresizingMask = [.width, .height]
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
hosting.autoresizingMask = [.width, .height]
window.contentView = hosting
// Ensure the underlying window content is clipped to rounded corners
@ -162,18 +182,17 @@ class LockScreenPanelManager {
}
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
guard let window = panelWindow, let baseFrame = collapsedFrame else {
guard let window = panelWindow, let screen = currentScreen() else {
return
}
let resizeDuration: CFTimeInterval = 0.28
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width
let targetHeight = baseSize.height + additionalHeight
let originX = baseFrame.midX - (targetWidth / 2)
let originY = baseFrame.origin.y
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
let targetFrame = targetFrame(
for: screen.frame,
panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight)
)
if animated {
NSAnimationContext.runAnimationGroup { context in
@ -185,7 +204,7 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true)
}
latestFrame = targetFrame
publishPanelFrame(targetFrame)
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
@ -229,7 +248,7 @@ class LockScreenPanelManager {
guard let window = panelWindow else {
print("LockScreenPanelManager: no panel to hide")
latestFrame = nil
publishPanelFrame(nil)
return
}
@ -239,7 +258,7 @@ class LockScreenPanelManager {
await MainActor.run {
window?.orderOut(nil)
window?.contentView = nil
self.latestFrame = nil
self.publishPanelFrame(nil)
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
}
}
@ -267,6 +286,74 @@ class LockScreenPanelManager {
.store(in: &cancellables)
}
private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect {
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback {
return spotifyCanvasFallbackPanelFrame(
screenFrame: screenFrame,
defaultCollapsedFrame: defaultCollapsedFrame,
panelSize: panelSize
)
}
let originX = defaultCollapsedFrame.midX - (panelSize.width / 2)
return NSRect(
x: originX,
y: defaultCollapsedFrame.origin.y,
width: panelSize.width,
height: panelSize.height
)
}
private func spotifyCanvasFallbackPanelFrame(
screenFrame: NSRect,
defaultCollapsedFrame: NSRect,
panelSize: CGSize
) -> NSRect {
let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame)
let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength(
screenFrame: screenFrame,
panelSize: panelSize
)
let totalWidth = artworkSide + spacing + panelSize.width
let horizontalMargin: CGFloat = 48
let minGroupOriginX = screenFrame.minX + horizontalMargin
let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX)
let groupOriginX = min(
max(screenFrame.midX - (totalWidth / 2), minGroupOriginX),
maxGroupOriginX
)
let baseCenterY = screenFrame.midY
let largestHalfHeight = max(artworkSide, panelSize.height) / 2
let minCenterY = screenFrame.minY + largestHalfHeight + 56
var maxCenterY = screenFrame.maxY - largestHalfHeight - 84
let widgetClearance: CGFloat = 20
if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame,
!weatherFrame.isEmpty {
let weatherTopConstrainedCenterY = weatherFrame.minY - widgetClearance - largestHalfHeight
maxCenterY = min(maxCenterY, weatherTopConstrainedCenterY)
}
if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame,
!timerFrame.isEmpty {
let timerTopConstrainedCenterY = timerFrame.minY - widgetClearance - largestHalfHeight
maxCenterY = min(maxCenterY, timerTopConstrainedCenterY)
}
let resolvedMaxCenterY = max(maxCenterY, minCenterY)
let centerY = min(max(baseCenterY, minCenterY), resolvedMaxCenterY)
return NSRect(
x: groupOriginX + artworkSide + spacing,
y: centerY - (panelSize.height / 2),
width: panelSize.width,
height: panelSize.height
)
}
private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2)
@ -289,3 +376,7 @@ class LockScreenPanelManager {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
}
}
extension Notification.Name {
static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange")
}

View file

@ -279,6 +279,11 @@ final class LockScreenTimerWidgetPanelManager {
originY = min(originY, topLimit)
}
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
let minAllowedY = musicFrame.maxY + 12
originY = max(originY, minAllowedY)
}
let minY = screen.frame.minY + 100
originY = max(originY, minY)

View file

@ -24,7 +24,11 @@ import Darwin
final class MemoryUsageMonitor {
static let shared = MemoryUsageMonitor()
#if DEBUG
private let thresholdBytes: UInt64 = 4 * 1_024 * 1_024 * 1_024
#else
private let thresholdBytes: UInt64 = 1_024 * 1_024 * 1_024
#endif
private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact
private let restartCooldown: TimeInterval = 300
private let logSampleInterval: TimeInterval = 300

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(
systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art"
@ -475,10 +485,16 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<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 explicitLookupKey: String?
private var artworkData: Data? = nil
private(set) var artworkData: Data? = nil
@Published var videoArtworkURL: URL? = nil
private var liveStreamUnknownDurationCount: Int = 0
private var liveStreamEdgeObservationCount: Int = 0
@ -512,6 +528,13 @@ class MusicManager: ObservableObject {
}
.store(in: &cancellables)
Defaults.publisher(.enableLyrics)
.receive(on: RunLoop.main)
.sink { [weak self] change in
self?.handleLyricsPreferenceChange(isEnabled: change.newValue)
}
.store(in: &cancellables)
// Observe Pear Desktop launch/terminate for auto-detection
setupPearDesktopAutoDetection()
@ -580,6 +603,8 @@ class MusicManager: ObservableObject {
public func destroy() {
debounceIdleTask?.cancel()
lyricsFetchTask?.cancel()
lyricSyncTask?.cancel()
explicitLookupTask?.cancel()
cancellables.removeAll()
controllerCancellables.removeAll()
@ -701,6 +726,7 @@ class MusicManager: ObservableObject {
// Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange =
titleChanged
|| artistChanged
@ -709,6 +735,11 @@ class MusicManager: ObservableObject {
|| bundleChanged
|| contentIdentifierChanged
|| contentURLChanged
let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL
if liveArtworkChanged {
self.videoArtworkURL = state.liveArtworkURL
}
// Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
@ -735,10 +766,16 @@ class MusicManager: ObservableObject {
self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL
// Fetch lyrics for new track whenever content changes
self.fetchLyrics()
self.prepareLyricsForCurrentTrack()
if let liveArtworkURL = state.liveArtworkURL {
self.videoArtworkURL = liveArtworkURL
} else {
self.fetchVideoArtwork()
}
self.refreshExplicitFlag(for: state)
// Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek()
@ -1238,53 +1275,108 @@ class MusicManager: ObservableObject {
// MARK: - Lyrics Methods
func fetchLyrics() {
guard Defaults[.enableLyrics] else { return }
// If the lyrics panel is visible already, provide immediate feedback
if showLyrics {
Task { @MainActor in
self.currentLyrics = "Loading lyrics..."
self.syncedLyrics = []
self.currentLyricIndex = -1
}
prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics])
}
private func handleLyricsPreferenceChange(isEnabled: Bool) {
showLyrics = isEnabled
if isEnabled {
prepareLyricsForCurrentTrack(prioritizeVisibleResult: true)
} else {
stopLyricSync()
}
}
private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) {
guard let lookup = currentLyricsLookupContext() else {
activeLyricsKey = nil
lyricsFetchKey = nil
lyricsFetchTask?.cancel()
lyricsFetchTask = nil
syncedLyrics = []
currentLyrics = ""
currentLyricIndex = -1
stopLyricSync()
return
}
Task {
do {
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle)
await MainActor.run {
self.syncedLyrics = lyrics
self.currentLyricIndex = -1
if !lyrics.isEmpty {
self.currentLyrics = lyrics[0].text
} else {
self.currentLyrics = ""
}
let key = lookup.key
let lyricsEnabled = Defaults[.enableLyrics]
let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
let trackChanged = activeLyricsKey != key
activeLyricsKey = key
// If lyrics are enabled, start syncing them to playback position
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
self.startLyricSync()
} else if self.syncedLyrics.isEmpty {
self.stopLyricSync()
}
if trackChanged {
syncedLyrics = []
currentLyricIndex = -1
currentLyrics = shouldShowLoading ? "Loading lyrics..." : ""
stopLyricSync()
}
if !forceFetch, let cachedLyrics = lyricsCache[key] {
applyLyricsToDisplay(cachedLyrics)
return
}
if lyricsFetchKey == key {
if shouldShowLoading && syncedLyrics.isEmpty {
currentLyrics = "Loading lyrics..."
}
return
}
lyricsFetchTask?.cancel()
lyricsFetchKey = key
if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) {
currentLyrics = "Loading lyrics..."
}
let requestArtist = lookup.requestArtist
let requestTitle = lookup.requestTitle
let requestAlbum = lookup.requestAlbum
lyricsFetchTask = Task { [weak self] in
guard let self else { return }
do {
let lyrics = try await self.fetchLyricsFromAPI(
artist: requestArtist,
title: requestTitle,
album: requestAlbum
)
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsCache[key] = lyrics
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.applyLyricsToDisplay(lyrics)
}
} catch {
print("Failed to fetch lyrics: \(error)")
await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
self.stopLyricSync()
}
}
}
}
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] {
private func fetchLyricsFromAPI(artist: String, title: String, album: String) async throws -> [LyricLine] {
guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current)
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return []
@ -1298,7 +1390,8 @@ class MusicManager: ObservableObject {
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = jsonArray.first {
let bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) {
let first = bestMatch
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -1340,6 +1433,107 @@ class MusicManager: ObservableObject {
}
}
private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? {
let requestArtist = normalizedLyricsRequestComponent(artistName)
let requestTitle = normalizedLyricsTitle(songTitle)
let requestAlbum = normalizedLyricsRequestComponent(album)
let key = LyricsLookupKey(
title: requestTitle.lowercased(),
artist: requestArtist.lowercased(),
album: requestAlbum.lowercased()
)
return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil
}
private func normalizedLyricsRequestComponent(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
}
private func normalizedLyricsTitle(_ value: String) -> String {
var normalized = normalizedLyricsRequestComponent(value)
let cleanupPatterns = [
"\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)",
"\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]",
"\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$",
"\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$",
"\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$"
]
for pattern in cleanupPatterns {
normalized = normalized.replacingOccurrences(
of: pattern,
with: "",
options: [.regularExpression, .caseInsensitive]
)
}
return normalizedLyricsRequestComponent(normalized)
}
private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? {
let normalizedArtist = artist.lowercased()
let normalizedTitle = title.lowercased()
let normalizedAlbum = album.lowercased()
return results.max { lhs, rhs in
lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
< lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
}
}
private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int {
let resultArtist = ((result["artistName"] as? String) ?? "").lowercased()
let resultTitle = ((result["trackName"] as? String) ?? "").lowercased()
let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased()
var score = 0
if resultTitle == title { score += 8 }
else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 }
if resultArtist == artist { score += 8 }
else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 }
if !album.isEmpty {
if resultAlbum == album { score += 4 }
else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 }
}
if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
score += 3
}
return score
}
private func applyLyricsToDisplay(_ lyrics: [LyricLine]) {
syncedLyrics = lyrics
currentLyricIndex = -1
guard !lyrics.isEmpty else {
currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : ""
stopLyricSync()
return
}
let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime)
updateCurrentLyric(for: playbackPosition)
if currentLyricIndex == -1, let firstLine = lyrics.first?.text {
currentLyrics = firstLine
}
if Defaults[.enableLyrics] {
startLyricSync()
} else {
stopLyricSync()
}
}
private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = []
@ -1424,6 +1618,32 @@ class MusicManager: ObservableObject {
lyricSyncTask = nil
}
// MARK: - Video Artwork
func fetchVideoArtwork() {
guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else {
videoArtworkURL = nil
return
}
// Se il player non è Apple Music, non toccare videoArtworkURL:
// SpotifyController gestisce il canvas in modo autonomo tramite liveArtworkURL.
guard bundleIdentifier == "com.apple.Music" else {
return
}
let title = songTitle
let artist = artistName
Task {
let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL(
title: title, artist: artist
)
await MainActor.run {
self.videoArtworkURL = url
}
}
}
func toggleLyrics() {
// Toggle the UI state first so the views can react immediately.
showLyrics.toggle()
@ -1518,7 +1738,6 @@ private struct AlbumArtFlipModifier: ViewModifier {
// Use a small tolerance to avoid flickering exactly at 90°/270°.
if cos > 0.001 { return 1 }
if cos < -0.001 { return -1 }
// At the exact edge, prefer the side we're animating toward.
return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1
}
}

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 os
class SystemOSDManager {
private init() {}
// Tracks the PID we most recently suspended. macOS jetsam-exits OSDUIHelper
// when idle and launchd respawns it on the next media-key press as a fresh
// process, so we need to re-SIGSTOP every new incarnation.
private struct SuppressionState {
var task: Task<Void, Never>?
var lastSuspendedPID: Int32 = -1
}
private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState())
/// Re-enables the system HUD by restarting OSDUIHelper
public static func enableSystemHUD() {
stopSuppressionWatcher()
Task.detached(priority: .background) {
await enableSystemHUDAsync()
}
@ -77,11 +88,14 @@ class SystemOSDManager {
}
}
/// Disables the system HUD by stopping OSDUIHelper
/// Disables the system HUD by stopping OSDUIHelper, and starts a
/// background watcher that re-suspends any future incarnation launchd
/// spawns (macOS auto-exits OSDUIHelper on idle).
public static func disableSystemHUD() {
Task.detached(priority: .background) {
await disableSystemHUDAsync()
}
startSuppressionWatcher()
}
private static func disableSystemHUDAsync() async {
@ -92,15 +106,35 @@ class SystemOSDManager {
kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"]
try kickstart.run()
kickstart.waitUntilExit()
try await Task.sleep(nanoseconds: 500_000_000) // 500ms - async wait instead of usleep
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["-STOP", "OSDUIHelper"]
try task.run()
task.waitUntilExit()
// launchctl kickstart returns once the request is queued, not after
// OSDUIHelper has actually forked. At cold boot the helper can take
// a while to appear — a fixed sleep races and the SIGSTOP misses,
// letting the native OSD render on the first volume/brightness key.
// Poll for the PID up to ~5s, then suspend, and retry if launchd
// respawned a fresh copy between kickstart and SIGSTOP.
var attempts = 0
while attempts < 3 {
let appeared = await waitForOSDUIHelper(timeoutMillis: 5000)
if !appeared {
await MainActor.run {
NSLog("⚠️ OSDUIHelper did not appear within timeout; retrying SIGSTOP anyway")
}
}
suspendOSDUIHelper()
// Settle, then confirm a process is actually present (and thus
// suspended). If none is running, launchd hasn't spawned it yet
// or the prior STOP raced — loop and try again.
try await Task.sleep(nanoseconds: 250_000_000) // 250ms
if let pid = osduiHelperPID() {
suppressionState.withLock { $0.lastSuspendedPID = pid }
break
}
attempts += 1
}
await MainActor.run {
print("✅ System HUD disabled")
}
@ -110,7 +144,92 @@ class SystemOSDManager {
}
}
}
/// Polls for an OSDUIHelper process, returning true as soon as one appears
/// or false if `timeoutMillis` elapses with no match.
private static func waitForOSDUIHelper(timeoutMillis: Int) async -> Bool {
let pollIntervalNanos: UInt64 = 200_000_000 // 200ms
let maxAttempts = max(1, timeoutMillis / 200)
for _ in 0..<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
public static func isOSDUIHelperRunning() -> Bool {
let task = Process()

View file

@ -926,6 +926,10 @@ extension Defaults.Keys {
)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", 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 lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
@ -1014,6 +1018,10 @@ extension Defaults.Keys {
// MARK: Media Controller
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
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
@ -1216,6 +1224,7 @@ extension Defaults.Keys {
// MARK: Lyrics Feature
static let enableLyrics = Key<Bool>("enableLyrics", default: false)
static let showLiveCanvasInDynamicIsland = Key<Bool>("showLiveCanvasInDynamicIsland", default: false)
// MARK: Notes Feature
static let enableNotes = Key<Bool>("enableNotes", default: false)

View file

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