mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Atoll: fullscreen album artwork as lockscreen wallpaper
Right-click on the album art in the lock screen music panel to set it as the desktop wallpaper. Right-click again or click anywhere on the background to restore the original wallpaper. Wallpaper also restores automatically on track change or screen unlock. Uses direct plist manipulation of the wallpaper config to avoid leaving traces in the wallpaper picker. Album art PNG is pre-cached in the background on track change for instant application. Adds video artwork URL fetching from iTunes catalog for Apple Music tracks (musicVideo entity search). Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
This commit is contained in:
parent
78bf018fe2
commit
5bd84270aa
12 changed files with 1946 additions and 83 deletions
|
|
@ -454,12 +454,12 @@
|
||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
DEVELOPMENT_TEAM = RPC964BP8A;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
@ -526,13 +526,13 @@
|
||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
|
"DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
|
||||||
|
|
@ -2314,7 +2314,6 @@
|
||||||
},
|
},
|
||||||
"±10 Seconds" : {
|
"±10 Seconds" : {
|
||||||
"comment" : "Display name for the \"±10 Seconds\" music skip behavior option.",
|
"comment" : "Display name for the \"±10 Seconds\" music skip behavior option.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -2438,6 +2437,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"1. Open Spotify in a browser and log in" : {
|
||||||
|
"comment" : "A step in the process of getting a Spotify cookie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"1m" : {
|
"1m" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -2511,6 +2514,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com" : {
|
||||||
|
"comment" : "A step to copy the Spotify cookie from the developer tools.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"3 items" : {
|
"3 items" : {
|
||||||
"comment" : "merge:ko:alt='항목 3개'",
|
"comment" : "merge:ko:alt='항목 3개'",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -2546,6 +2553,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"3. Copy the value of `sp_dc` and paste it here" : {
|
||||||
|
"comment" : "A step to paste the Spotify cookie into the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"5 colors" : {
|
"5 colors" : {
|
||||||
"comment" : "merge:ko:alt='5가지 색상'",
|
"comment" : "merge:ko:alt='5가지 색상'",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -3138,7 +3149,6 @@
|
||||||
},
|
},
|
||||||
"Accent" : {
|
"Accent" : {
|
||||||
"comment" : "Display name for the caps lock indicator tint mode.",
|
"comment" : "Display name for the caps lock indicator tint mode.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -3696,7 +3706,6 @@
|
||||||
},
|
},
|
||||||
"Adaptive gradient" : {
|
"Adaptive gradient" : {
|
||||||
"comment" : "Display name for the timer icon color mode: adaptive gradient.",
|
"comment" : "Display name for the timer icon color mode: adaptive gradient.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -5623,6 +5632,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Amazon Music" : {
|
||||||
|
"comment" : "Spotify music service.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Animation" : {
|
"Animation" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -6016,7 +6029,6 @@
|
||||||
},
|
},
|
||||||
"Apple Music" : {
|
"Apple Music" : {
|
||||||
"comment" : "Media controller type for Apple Music",
|
"comment" : "Media controller type for Apple Music",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -6386,6 +6398,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track." : {
|
||||||
|
"comment" : "A description of how Spotify cookies are used by Atoll.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : {
|
"Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : {
|
||||||
"comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.",
|
"comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -6920,7 +6936,6 @@
|
||||||
},
|
},
|
||||||
"Bar" : {
|
"Bar" : {
|
||||||
"comment" : "Name of the timer progress style that shows a horizontal bar.",
|
"comment" : "Name of the timer progress style that shows a horizontal bar.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -9952,7 +9967,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Clear" : {
|
"Clear" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -14521,7 +14535,6 @@
|
||||||
},
|
},
|
||||||
"Digital" : {
|
"Digital" : {
|
||||||
"comment" : "The display name for the \"Digital\" reminder presentation style.",
|
"comment" : "The display name for the \"Digital\" reminder presentation style.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -20829,7 +20842,6 @@
|
||||||
},
|
},
|
||||||
"Green" : {
|
"Green" : {
|
||||||
"comment" : "Display name for the green caps lock indicator tint mode.",
|
"comment" : "Display name for the green caps lock indicator tint mode.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -21574,6 +21586,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"How to get it:" : {
|
||||||
|
"comment" : "A heading for the instructions on how to get the Spotify cookie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"https://example.com/animation.json" : {
|
"https://example.com/animation.json" : {
|
||||||
"comment" : "A placeholder text for a Lottie JSON URL input field.",
|
"comment" : "A placeholder text for a Lottie JSON URL input field.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -26270,6 +26286,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Method source" : {
|
||||||
|
"comment" : "A link to the source code of Spotify's method for requesting a Spotify web player token.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Mic" : {
|
"Mic" : {
|
||||||
"comment" : "Name of a sneak action type that controls the microphone.",
|
"comment" : "Name of a sneak action type that controls the microphone.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -29944,7 +29964,6 @@
|
||||||
},
|
},
|
||||||
"Now Playing" : {
|
"Now Playing" : {
|
||||||
"comment" : "Name of the media controller type for the \"Now Playing\" option.",
|
"comment" : "Name of the media controller type for the \"Now Playing\" option.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -30436,6 +30455,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Open Spotify Web Player" : {
|
||||||
|
"comment" : "A link to open Spotify.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Open System Settings" : {
|
"Open System Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -30690,7 +30713,6 @@
|
||||||
},
|
},
|
||||||
"Panel" : {
|
"Panel" : {
|
||||||
"comment" : "Display name for the clipboard display mode \"Panel\".",
|
"comment" : "Display name for the clipboard display mode \"Panel\".",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -30791,6 +30813,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Paste from Clipboard" : {
|
||||||
|
"comment" : "A button that copies the `sp_dc` cookie from the clipboard.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Paste the `sp_dc` cookie from your logged-in Spotify web session." : {
|
||||||
|
"comment" : "A description of how to paste the Spotify cookie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Pause" : {
|
"Pause" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ru" : {
|
"ru" : {
|
||||||
|
|
@ -34233,7 +34263,6 @@
|
||||||
},
|
},
|
||||||
"Ring" : {
|
"Ring" : {
|
||||||
"comment" : "The name of the timer progress style that shows a ring shape.",
|
"comment" : "The name of the timer progress style that shows a ring shape.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -35755,7 +35784,6 @@
|
||||||
},
|
},
|
||||||
"Separate Tab" : {
|
"Separate Tab" : {
|
||||||
"comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.",
|
"comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -38188,7 +38216,6 @@
|
||||||
},
|
},
|
||||||
"Shows screen assistant as a dropdown attached to the AI button" : {
|
"Shows screen assistant as a dropdown attached to the AI button" : {
|
||||||
"comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.",
|
"comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -38213,7 +38240,6 @@
|
||||||
},
|
},
|
||||||
"Shows screen assistant in a floating panel near the notch" : {
|
"Shows screen assistant in a floating panel near the notch" : {
|
||||||
"comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.",
|
"comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -38441,7 +38467,6 @@
|
||||||
},
|
},
|
||||||
"Skip forward or backward by ten seconds" : {
|
"Skip forward or backward by ten seconds" : {
|
||||||
"comment" : "Description of a music skip behavior option.",
|
"comment" : "Description of a music skip behavior option.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -39034,6 +39059,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sp_dc cookie" : {
|
||||||
|
"comment" : "A label for the Spotify `sp_dc` cookie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Speed" : {
|
"Speed" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -39165,7 +39194,6 @@
|
||||||
},
|
},
|
||||||
"Spotify" : {
|
"Spotify" : {
|
||||||
"comment" : "Spotify media controller type.",
|
"comment" : "Spotify media controller type.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -39188,6 +39216,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Spotify Canvas Session" : {
|
||||||
|
"comment" : "A section header for the Spotify Canvas session settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Square" : {
|
"Square" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -39402,7 +39434,6 @@
|
||||||
},
|
},
|
||||||
"Standard previous/next track controls" : {
|
"Standard previous/next track controls" : {
|
||||||
"comment" : "Description of the music skip behavior option to use standard previous/next track controls.",
|
"comment" : "Description of the music skip behavior option to use standard previous/next track controls.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -42230,7 +42261,6 @@
|
||||||
},
|
},
|
||||||
"Track Skip" : {
|
"Track Skip" : {
|
||||||
"comment" : "Display name for the \"Track Skip\" music skip behavior.",
|
"comment" : "Display name for the \"Track Skip\" music skip behavior.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
@ -43892,6 +43922,14 @@
|
||||||
},
|
},
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"Validate Cookie" : {
|
||||||
|
"comment" : "A button that validates a Spotify session cookie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Validating..." : {
|
||||||
|
"comment" : "The text of a button that is shown while Spotify's session is being validated.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Variant %lld" : {
|
"Variant %lld" : {
|
||||||
"comment" : "A label for a specific variant of the Liquid Glass.",
|
"comment" : "A label for a specific variant of the Liquid Glass.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|
@ -45595,7 +45633,6 @@
|
||||||
},
|
},
|
||||||
"Youtube Music" : {
|
"Youtube Music" : {
|
||||||
"comment" : "Spotify music service.",
|
"comment" : "Spotify music service.",
|
||||||
"extractionState" : "stale",
|
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ko" : {
|
"ko" : {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class SpotifyController: MediaControllerProtocol {
|
class SpotifyController: MediaControllerProtocol {
|
||||||
|
|
@ -37,19 +37,26 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
}
|
}
|
||||||
|
|
||||||
var isWorking: Bool {
|
var isWorking: Bool {
|
||||||
return true // Spotify controller always works
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
private var notificationTask: Task<Void, Never>?
|
private var notificationTask: Task<Void, Never>?
|
||||||
|
private var sessionChangeCancellable: AnyCancellable?
|
||||||
|
|
||||||
// Constant for time between command and update
|
// Constant for time between command and update
|
||||||
private let commandUpdateDelay: Duration = .milliseconds(25)
|
private let commandUpdateDelay: Duration = .milliseconds(25)
|
||||||
|
|
||||||
private var lastArtworkURL: String?
|
private var lastArtworkURL: String?
|
||||||
private var artworkFetchTask: Task<Void, Never>?
|
private var artworkFetchTask: Task<Void, Never>?
|
||||||
|
private var canvasFetchTask: Task<Void, Never>?
|
||||||
|
private var currentCanvasTrackURI: String?
|
||||||
|
private var lastCanvasRequestTrackURI: String?
|
||||||
|
private var lastCanvasRequestDate: Date = .distantPast
|
||||||
|
private let canvasResolver = SpotifyCanvasResolver()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
setupPlaybackStateChangeObserver()
|
setupPlaybackStateChangeObserver()
|
||||||
|
setupSessionChangeObserver()
|
||||||
Task {
|
Task {
|
||||||
if isActive() {
|
if isActive() {
|
||||||
await updatePlaybackInfo()
|
await updatePlaybackInfo()
|
||||||
|
|
@ -69,9 +76,27 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupSessionChangeObserver() {
|
||||||
|
sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.lastCanvasRequestTrackURI = nil
|
||||||
|
self?.lastCanvasRequestDate = .distantPast
|
||||||
|
if let self {
|
||||||
|
var updatedState = self.playbackState
|
||||||
|
updatedState.liveArtworkURL = nil
|
||||||
|
self.playbackState = updatedState
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await self?.updatePlaybackInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
notificationTask?.cancel()
|
notificationTask?.cancel()
|
||||||
artworkFetchTask?.cancel()
|
artworkFetchTask?.cancel()
|
||||||
|
canvasFetchTask?.cancel()
|
||||||
|
sessionChangeCancellable?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Implementation
|
// MARK: - Protocol Implementation
|
||||||
|
|
@ -109,12 +134,13 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||||
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
||||||
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
||||||
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000
|
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000
|
||||||
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||||
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
|
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
|
||||||
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
|
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
|
||||||
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? ""
|
||||||
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? ""
|
||||||
|
let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString)
|
||||||
|
|
||||||
var state = PlaybackState(
|
var state = PlaybackState(
|
||||||
bundleIdentifier: Self.bundleIdentifier,
|
bundleIdentifier: Self.bundleIdentifier,
|
||||||
|
|
@ -129,14 +155,30 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
repeatMode: isRepeating ? .all : .off,
|
repeatMode: isRepeating ? .all : .off,
|
||||||
lastUpdated: Date()
|
lastUpdated: Date()
|
||||||
)
|
)
|
||||||
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier
|
state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL
|
? nil
|
||||||
|
: trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? nil
|
||||||
|
: spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
|
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
|
||||||
state.artwork = existingArtwork
|
state.artwork = existingArtwork
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackState = state
|
if trackURI == currentCanvasTrackURI {
|
||||||
|
state.liveArtworkURL = playbackState.liveArtworkURL
|
||||||
|
} else {
|
||||||
|
currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI
|
||||||
|
canvasFetchTask?.cancel()
|
||||||
|
canvasFetchTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackState = state
|
||||||
|
|
||||||
|
if !trackURI.isEmpty {
|
||||||
|
scheduleCanvasFetchIfNeeded(for: trackURI)
|
||||||
|
}
|
||||||
|
|
||||||
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
|
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
|
||||||
guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
|
guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
|
||||||
|
|
@ -152,6 +194,7 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
var updatedState = currentState
|
var updatedState = currentState
|
||||||
updatedState.artwork = data
|
updatedState.artwork = data
|
||||||
|
updatedState.liveArtworkURL = self.playbackState.liveArtworkURL
|
||||||
self.playbackState = updatedState
|
self.playbackState = updatedState
|
||||||
self.lastArtworkURL = artworkURL
|
self.lastArtworkURL = artworkURL
|
||||||
self.artworkFetchTask = nil
|
self.artworkFetchTask = nil
|
||||||
|
|
@ -165,7 +208,41 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func scheduleCanvasFetchIfNeeded(for trackURI: String) {
|
||||||
|
guard !trackURI.isEmpty else { return }
|
||||||
|
guard playbackState.liveArtworkURL == nil else { return }
|
||||||
|
guard canvasFetchTask == nil else { return }
|
||||||
|
|
||||||
|
if lastCanvasRequestTrackURI == trackURI,
|
||||||
|
Date().timeIntervalSince(lastCanvasRequestDate) < 5
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCanvasRequestTrackURI = trackURI
|
||||||
|
lastCanvasRequestDate = Date()
|
||||||
|
|
||||||
|
canvasFetchTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
let canvasURL = await self.canvasResolver.canvasURL(for: trackURI)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
guard self.currentCanvasTrackURI == trackURI else {
|
||||||
|
self.canvasFetchTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedState = self.playbackState
|
||||||
|
updatedState.liveArtworkURL = canvasURL
|
||||||
|
self.playbackState = updatedState
|
||||||
|
self.canvasFetchTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func executeCommand(_ command: String) async {
|
private func executeCommand(_ command: String) async {
|
||||||
let script = "tell application \"Spotify\" to \(command)"
|
let script = "tell application \"Spotify\" to \(command)"
|
||||||
|
|
@ -192,15 +269,9 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
set shuffleState to shuffling
|
set shuffleState to shuffling
|
||||||
set repeatState to repeating
|
set repeatState to repeating
|
||||||
set artworkURL to artwork url of current track
|
set artworkURL to artwork url of current track
|
||||||
set trackIdentifier to ""
|
set trackID to id of current track
|
||||||
try
|
set spotifyURL to spotify url of current track
|
||||||
set trackIdentifier to id of current track
|
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL}
|
||||||
end try
|
|
||||||
set trackSpotifyURL to ""
|
|
||||||
try
|
|
||||||
set trackSpotifyURL to spotify url of current track
|
|
||||||
end try
|
|
||||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL}
|
|
||||||
on error
|
on error
|
||||||
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
|
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
|
||||||
end try
|
end try
|
||||||
|
|
@ -209,4 +280,283 @@ class SpotifyController: MediaControllerProtocol {
|
||||||
|
|
||||||
return try await AppleScriptHelper.execute(script)
|
return try await AppleScriptHelper.execute(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String {
|
||||||
|
let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedIdentifier.hasPrefix("spotify:track:") {
|
||||||
|
return trimmedIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLikelySpotifyTrackID(trimmedIdentifier) {
|
||||||
|
return "spotify:track:\(trimmedIdentifier)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let trackID = extractTrackID(from: spotifyURLString) {
|
||||||
|
return "spotify:track:\(trackID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractTrackID(from spotifyURLString: String) -> String? {
|
||||||
|
guard let url = URL(string: spotifyURLString),
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let pathComponents = components.path.split(separator: "/")
|
||||||
|
guard let trackIndex = pathComponents.firstIndex(of: "track"),
|
||||||
|
trackIndex + 1 < pathComponents.count
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let identifier = String(pathComponents[trackIndex + 1])
|
||||||
|
return isLikelySpotifyTrackID(identifier) ? identifier : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLikelySpotifyTrackID(_ value: String) -> Bool {
|
||||||
|
value.count == 22 && value.unicodeScalars.allSatisfy { scalar in
|
||||||
|
CharacterSet.alphanumerics.contains(scalar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor SpotifyCanvasResolver {
|
||||||
|
private struct CacheEntry {
|
||||||
|
let url: URL?
|
||||||
|
let expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cache: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
|
func canvasURL(for trackURI: String) async -> URL? {
|
||||||
|
let now = Date()
|
||||||
|
if let entry = cache[trackURI], entry.expiresAt > now {
|
||||||
|
return entry.url
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true)
|
||||||
|
let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600
|
||||||
|
cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime))
|
||||||
|
return resolvedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? {
|
||||||
|
guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI)
|
||||||
|
request.setValue("application/protobuf", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("en", forHTTPHeaderField: "Accept-Language")
|
||||||
|
request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent")
|
||||||
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401, retryOnUnauthorized {
|
||||||
|
await SpotifyAuthManager.shared.invalidateAccessToken()
|
||||||
|
return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SpotifyCanvasProtobuf {
|
||||||
|
static func makeCanvasRequest(trackURI: String) -> Data {
|
||||||
|
let uriData = Data(trackURI.utf8)
|
||||||
|
|
||||||
|
var trackMessage = Data()
|
||||||
|
trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2))
|
||||||
|
trackMessage.append(varint(for: uriData.count))
|
||||||
|
trackMessage.append(uriData)
|
||||||
|
|
||||||
|
var request = Data()
|
||||||
|
request.append(protobufKey(fieldNumber: 1, wireType: 2))
|
||||||
|
request.append(varint(for: trackMessage.count))
|
||||||
|
request.append(trackMessage)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? {
|
||||||
|
do {
|
||||||
|
var reader = ProtobufReader(data: data)
|
||||||
|
var matchingURLs: [URL] = []
|
||||||
|
|
||||||
|
while !reader.isAtEnd {
|
||||||
|
let key = try reader.readVarint()
|
||||||
|
let fieldNumber = Int(key >> 3)
|
||||||
|
let wireType = Int(key & 0x7)
|
||||||
|
|
||||||
|
switch (fieldNumber, wireType) {
|
||||||
|
case (1, 2):
|
||||||
|
let canvasData = try reader.readLengthDelimited()
|
||||||
|
if let record = try parseCanvasRecord(from: canvasData),
|
||||||
|
record.trackURI == trackURI,
|
||||||
|
let canvasURL = record.canvasURL
|
||||||
|
{
|
||||||
|
matchingURLs.append(canvasURL)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
try reader.skipField(wireType: wireType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? {
|
||||||
|
var reader = ProtobufReader(data: data)
|
||||||
|
var trackURI = ""
|
||||||
|
var canvasURL: URL?
|
||||||
|
|
||||||
|
while !reader.isAtEnd {
|
||||||
|
let key = try reader.readVarint()
|
||||||
|
let fieldNumber = Int(key >> 3)
|
||||||
|
let wireType = Int(key & 0x7)
|
||||||
|
|
||||||
|
switch (fieldNumber, wireType) {
|
||||||
|
case (2, 2):
|
||||||
|
let urlString = try reader.readString()
|
||||||
|
canvasURL = URL(string: urlString)
|
||||||
|
case (5, 2):
|
||||||
|
trackURI = try reader.readString()
|
||||||
|
default:
|
||||||
|
try reader.skipField(wireType: wireType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !trackURI.isEmpty else { return nil }
|
||||||
|
return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data {
|
||||||
|
var key = Data()
|
||||||
|
key.append(varint(for: (fieldNumber << 3) | wireType))
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func varint(for value: Int) -> Data {
|
||||||
|
var remaining = UInt64(value)
|
||||||
|
var buffer = Data()
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
var nextByte = UInt8(remaining & 0x7F)
|
||||||
|
remaining >>= 7
|
||||||
|
if remaining != 0 {
|
||||||
|
nextByte |= 0x80
|
||||||
|
}
|
||||||
|
buffer.append(nextByte)
|
||||||
|
} while remaining != 0
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SpotifyCanvasRecord {
|
||||||
|
let trackURI: String
|
||||||
|
let canvasURL: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ProtobufReader {
|
||||||
|
enum ReaderError: Error {
|
||||||
|
case malformedVarint
|
||||||
|
case unexpectedEnd
|
||||||
|
case unsupportedWireType
|
||||||
|
}
|
||||||
|
|
||||||
|
private let data: Data
|
||||||
|
private var index: Data.Index
|
||||||
|
|
||||||
|
init(data: Data) {
|
||||||
|
self.data = data
|
||||||
|
self.index = data.startIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAtEnd: Bool {
|
||||||
|
index >= data.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readVarint() throws -> UInt64 {
|
||||||
|
var result: UInt64 = 0
|
||||||
|
var shift: UInt64 = 0
|
||||||
|
|
||||||
|
while shift < 64 {
|
||||||
|
guard index < data.endIndex else {
|
||||||
|
throw ReaderError.unexpectedEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
let byte = data[index]
|
||||||
|
index = data.index(after: index)
|
||||||
|
|
||||||
|
result |= UInt64(byte & 0x7F) << shift
|
||||||
|
if (byte & 0x80) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ReaderError.malformedVarint
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readLengthDelimited() throws -> Data {
|
||||||
|
let length = Int(try readVarint())
|
||||||
|
guard length >= 0 else {
|
||||||
|
throw ReaderError.unexpectedEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex)
|
||||||
|
guard let endIndex else {
|
||||||
|
throw ReaderError.unexpectedEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = data[index..<endIndex]
|
||||||
|
index = endIndex
|
||||||
|
return Data(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readString() throws -> String {
|
||||||
|
let bytes = try readLengthDelimited()
|
||||||
|
return String(decoding: bytes, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func skipField(wireType: Int) throws {
|
||||||
|
switch wireType {
|
||||||
|
case 0:
|
||||||
|
_ = try readVarint()
|
||||||
|
case 1:
|
||||||
|
try advance(by: 8)
|
||||||
|
case 2:
|
||||||
|
_ = try readLengthDelimited()
|
||||||
|
case 5:
|
||||||
|
try advance(by: 4)
|
||||||
|
default:
|
||||||
|
throw ReaderError.unsupportedWireType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func advance(by count: Int) throws {
|
||||||
|
let endIndex = data.index(index, offsetBy: count, limitedBy: data.endIndex)
|
||||||
|
guard let endIndex else {
|
||||||
|
throw ReaderError.unexpectedEnd
|
||||||
|
}
|
||||||
|
index = endIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ struct LockScreenMusicPanel: View {
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var isVolumeSliderVisible = false
|
@State private var isVolumeSliderVisible = false
|
||||||
@State private var isAirPlayPopoverPresented = false
|
@State private var isAirPlayPopoverPresented = false
|
||||||
|
@State private var isArtworkFullscreen = false
|
||||||
|
|
||||||
@State private var collapseWorkItem: DispatchWorkItem?
|
@State private var collapseWorkItem: DispatchWorkItem?
|
||||||
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
||||||
|
|
@ -66,6 +67,8 @@ struct LockScreenMusicPanel: View {
|
||||||
@Default(.enableLyrics) private var enableLyrics
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
||||||
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
||||||
|
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
|
||||||
|
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
|
||||||
|
|
||||||
init(animator: LockScreenPanelAnimator) {
|
init(animator: LockScreenPanelAnimator) {
|
||||||
_animator = ObservedObject(wrappedValue: animator)
|
_animator = ObservedObject(wrappedValue: animator)
|
||||||
|
|
@ -163,6 +166,11 @@ struct LockScreenMusicPanel: View {
|
||||||
parallaxResumeWorkItem = nil
|
parallaxResumeWorkItem = nil
|
||||||
isParallaxSuspended = false
|
isParallaxSuspended = false
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
|
||||||
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
|
isArtworkFullscreen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: isExpanded) { _, expanded in
|
.onChange(of: isExpanded) { _, expanded in
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
}
|
}
|
||||||
|
|
@ -329,30 +337,43 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||||||
Button(action: toggleExpanded) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
if isArtworkFullscreen {
|
||||||
albumArtImage(size: size, cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
if showAppIcon, let icon = lockScreenAppIcon {
|
.fill(Color.white.opacity(0.06))
|
||||||
icon
|
.overlay(
|
||||||
.resizable()
|
Image(systemName: "photo.fill")
|
||||||
.aspectRatio(contentMode: .fit)
|
.font(.system(size: size * 0.3, weight: .light))
|
||||||
.frame(width: appIconSize, height: appIconSize)
|
.foregroundColor(.white.opacity(0.2))
|
||||||
|
)
|
||||||
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
} else {
|
||||||
.offset(x: appIconOffset, y: appIconOffset)
|
albumArtImage(size: size, cornerRadius: cornerRadius)
|
||||||
.transition(.scale.combined(with: .opacity))
|
if showAppIcon, let icon = lockScreenAppIcon {
|
||||||
}
|
icon
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: appIconSize, height: appIconSize)
|
||||||
|
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
||||||
|
.offset(x: appIconOffset, y: appIconOffset)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
}
|
}
|
||||||
.albumArtFlip(angle: musicManager.flipAngle)
|
}
|
||||||
.parallax3D(enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended)
|
|
||||||
.frame(width: size)
|
|
||||||
.background(albumArtBackground(cornerRadius: cornerRadius))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle)
|
||||||
|
.parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, suspended: isParallaxSuspended)
|
||||||
|
.frame(width: size)
|
||||||
|
.background(albumArtBackground(cornerRadius: cornerRadius))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
|
||||||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||||||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||||||
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
|
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
|
||||||
|
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
|
||||||
|
.onTapGesture {
|
||||||
|
toggleExpanded()
|
||||||
|
}
|
||||||
|
.onRightClick {
|
||||||
|
expandArtworkToFullscreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -384,6 +405,41 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func expandArtworkToFullscreen() {
|
||||||
|
guard fullscreenArtworkEnabled else { return }
|
||||||
|
|
||||||
|
if FullScreenArtworkWindowManager.shared.isShowing {
|
||||||
|
FullScreenArtworkWindowManager.shared.hide()
|
||||||
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
|
isArtworkFullscreen = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard musicManager.hasActiveSession else { return }
|
||||||
|
|
||||||
|
let artwork = musicManager.albumArt
|
||||||
|
let videoURL = musicManager.videoArtworkURL
|
||||||
|
|
||||||
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
|
isArtworkFullscreen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenArtworkWindowManager.shared.onDismiss = {
|
||||||
|
Task { @MainActor in
|
||||||
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
|
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenArtworkWindowManager.shared.show(
|
||||||
|
artwork: artwork,
|
||||||
|
videoURL: fullscreenVideoArtwork ? videoURL : nil,
|
||||||
|
allowLiveWallpaper: fullscreenVideoArtwork
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func registerInteraction() {
|
private func registerInteraction() {
|
||||||
cancelCollapseTimer()
|
cancelCollapseTimer()
|
||||||
guard isExpanded else { return }
|
guard isExpanded else { return }
|
||||||
|
|
@ -1384,3 +1440,48 @@ private struct GlassTextBackdrop: View {
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Right Click Gesture
|
||||||
|
|
||||||
|
private struct RightClickOverlay: ViewModifier {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay(
|
||||||
|
RightClickReceiverView(action: action)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RightClickReceiverView: NSViewRepresentable {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> RightClickNSView {
|
||||||
|
let view = RightClickNSView()
|
||||||
|
view.onRightClick = action
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: RightClickNSView, context: Context) {
|
||||||
|
nsView.onRightClick = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func onRightClick(perform action: @escaping () -> Void) -> some View {
|
||||||
|
modifier(RightClickOverlay(action: action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RightClickNSView: NSView {
|
||||||
|
var onRightClick: (() -> Void)?
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
onRightClick?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2505,6 +2505,11 @@ struct Media: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mediaController == .spotify {
|
||||||
|
SpotifyAuthSettingsSection()
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Defaults.Toggle(key: .showStandardMediaControls) {
|
Defaults.Toggle(key: .showStandardMediaControls) {
|
||||||
Text("Show media controls in Dynamic Island")
|
Text("Show media controls in Dynamic Island")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <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
|
||||||
|
|
||||||
|
private var hasCookie: Bool {
|
||||||
|
!SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(2...4)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
|
Text(spotifyAuthManager.sessionStatusText)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty {
|
||||||
|
Text(authErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Paste from Clipboard") {
|
||||||
|
pasteCookieFromClipboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") {
|
||||||
|
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie)
|
||||||
|
Task {
|
||||||
|
await spotifyAuthManager.validateSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!hasCookie || spotifyAuthManager.isAuthorizing)
|
||||||
|
|
||||||
|
Button("Clear") {
|
||||||
|
spotifySPDCCookie = ""
|
||||||
|
spotifyAuthManager.clearSession()
|
||||||
|
}
|
||||||
|
.disabled(!hasCookie && !spotifyAuthManager.isAuthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("How to get it:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("1. Open Spotify in a browser and log in")
|
||||||
|
.font(.caption)
|
||||||
|
Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com")
|
||||||
|
.font(.caption)
|
||||||
|
Text("3. Copy the value of `sp_dc` and paste it here")
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!)
|
||||||
|
Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
} header: {
|
||||||
|
Text("Spotify Canvas Session")
|
||||||
|
} footer: {
|
||||||
|
Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pasteCookieFromClipboard() {
|
||||||
|
guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return }
|
||||||
|
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText)
|
||||||
|
}
|
||||||
|
}
|
||||||
824
DynamicIsland/managers/FullScreenArtworkWindowManager.swift
Normal file
824
DynamicIsland/managers/FullScreenArtworkWindowManager.swift
Normal file
|
|
@ -0,0 +1,824 @@
|
||||||
|
/*
|
||||||
|
* Atoll (DynamicIsland)
|
||||||
|
* Copyright (C) 2024-2026 Atoll Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import SkyLightWindow
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Click Receiver Window
|
||||||
|
|
||||||
|
private class ClickReceiverWindow: NSWindow {
|
||||||
|
var onClick: (() -> Void)?
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
onClick?()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
onClick?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LoopingVideoView: NSView {
|
||||||
|
private var playerLayer: AVPlayerLayer?
|
||||||
|
private var queuePlayer: AVQueuePlayer?
|
||||||
|
private var playerLooper: AVPlayerLooper?
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
wantsLayer = true
|
||||||
|
layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
wantsLayer = true
|
||||||
|
layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
playerLayer?.frame = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func play(url: URL) {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
let playerItem = AVPlayerItem(url: url)
|
||||||
|
let player = AVQueuePlayer()
|
||||||
|
player.isMuted = true
|
||||||
|
player.actionAtItemEnd = .none
|
||||||
|
|
||||||
|
let layer = AVPlayerLayer(player: player)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
layer.frame = bounds
|
||||||
|
layer.backgroundColor = NSColor.clear.cgColor
|
||||||
|
|
||||||
|
self.layer?.sublayers?.forEach { $0.removeFromSuperlayer() }
|
||||||
|
self.layer?.addSublayer(layer)
|
||||||
|
|
||||||
|
playerLayer = layer
|
||||||
|
queuePlayer = player
|
||||||
|
playerLooper = AVPlayerLooper(player: player, templateItem: playerItem)
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
queuePlayer?.pause()
|
||||||
|
playerLooper = nil
|
||||||
|
queuePlayer = nil
|
||||||
|
playerLayer?.removeFromSuperlayer()
|
||||||
|
playerLayer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window Manager
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FullScreenArtworkWindowManager {
|
||||||
|
static let shared = FullScreenArtworkWindowManager()
|
||||||
|
|
||||||
|
private(set) var isShowing = false
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
|
private let wallpaperPlistURL: URL = {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist")
|
||||||
|
}()
|
||||||
|
private let backupPlistURL: URL = {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("atoll_wallpaper_backup.plist")
|
||||||
|
}()
|
||||||
|
private let aerialManifestURL: URL = {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json")
|
||||||
|
}()
|
||||||
|
private let aerialVideosDirectoryURL: URL = {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/videos", isDirectory: true)
|
||||||
|
}()
|
||||||
|
private let aerialThumbnailsDirectoryURL: URL = {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/thumbnails", isDirectory: true)
|
||||||
|
}()
|
||||||
|
private let customLiveWallpaperAssetID = "6D6834A4-2F0F-479A-B053-7D4DC5CB8EB7"
|
||||||
|
private let liveWallpaperManifestBackupURL: URL = {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("atoll_aerial_manifest_backup.json")
|
||||||
|
}()
|
||||||
|
private let liveWallpaperVideoBackupURL: URL = {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("atoll_aerial_video_backup.mov")
|
||||||
|
}()
|
||||||
|
private let liveWallpaperThumbnailBackupURL: URL = {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("atoll_aerial_thumbnail_backup.png")
|
||||||
|
}()
|
||||||
|
private var artworkFileURL: URL?
|
||||||
|
private var cachedArtworkPNG: URL?
|
||||||
|
private var cachedArtworkIdentifier: String?
|
||||||
|
private var activeSongTitle: String?
|
||||||
|
private var activeArtist: String?
|
||||||
|
private var trackChangeCancellable: AnyCancellable?
|
||||||
|
private var artworkCacheCancellable: AnyCancellable?
|
||||||
|
private var videoArtworkCancellable: AnyCancellable?
|
||||||
|
private var clickWindow: ClickReceiverWindow?
|
||||||
|
private var clickWindowDelegated = false
|
||||||
|
private var videoWindow: NSWindow?
|
||||||
|
private var videoView: LoopingVideoView?
|
||||||
|
private var liveWallpaperTask: Task<Void, Never>?
|
||||||
|
private var isLiveWallpaperAllowed = false
|
||||||
|
private var activeLiveWallpaperFingerprint: String?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
observeArtworkChanges()
|
||||||
|
observeVideoArtworkChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) {
|
||||||
|
guard !isShowing else { return }
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
|
||||||
|
let fileURL: URL
|
||||||
|
let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)"
|
||||||
|
|
||||||
|
if let cached = cachedArtworkPNG,
|
||||||
|
cachedArtworkIdentifier == artworkID,
|
||||||
|
FileManager.default.fileExists(atPath: cached.path) {
|
||||||
|
fileURL = cached
|
||||||
|
} else {
|
||||||
|
guard let encoded = encodeToPNG(artwork) else { return }
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png")
|
||||||
|
do {
|
||||||
|
try encoded.write(to: tempURL, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileURL = tempURL
|
||||||
|
cachedArtworkPNG = tempURL
|
||||||
|
cachedArtworkIdentifier = artworkID
|
||||||
|
}
|
||||||
|
|
||||||
|
artworkFileURL = fileURL
|
||||||
|
isLiveWallpaperAllowed = allowLiveWallpaper
|
||||||
|
activeLiveWallpaperFingerprint = nil
|
||||||
|
backupWallpaperConfig()
|
||||||
|
|
||||||
|
guard applyArtworkToPlist(imageURL: fileURL) else {
|
||||||
|
print("[FullScreenArtworkWindowManager] Failed to patch plist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restartWallpaperAgent()
|
||||||
|
hideVideoWindow()
|
||||||
|
showClickReceiver(on: screen)
|
||||||
|
|
||||||
|
isShowing = true
|
||||||
|
activeSongTitle = MusicManager.shared.songTitle
|
||||||
|
activeArtist = MusicManager.shared.artistName
|
||||||
|
|
||||||
|
observeTrackChanges()
|
||||||
|
scheduleLiveWallpaperPreparation(for: videoURL, artwork: artwork, identifier: artworkID)
|
||||||
|
|
||||||
|
print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
guard isShowing else { return }
|
||||||
|
isShowing = false
|
||||||
|
isLiveWallpaperAllowed = false
|
||||||
|
activeLiveWallpaperFingerprint = nil
|
||||||
|
trackChangeCancellable?.cancel()
|
||||||
|
trackChangeCancellable = nil
|
||||||
|
liveWallpaperTask?.cancel()
|
||||||
|
liveWallpaperTask = nil
|
||||||
|
|
||||||
|
hideVideoWindow()
|
||||||
|
hideClickReceiver()
|
||||||
|
restoreWallpaper()
|
||||||
|
|
||||||
|
activeSongTitle = nil
|
||||||
|
activeArtist = nil
|
||||||
|
|
||||||
|
let callback = onDismiss
|
||||||
|
onDismiss = nil
|
||||||
|
callback?()
|
||||||
|
|
||||||
|
print("[FullScreenArtworkWindowManager] Original wallpaper restored")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Artwork Pre-Cache
|
||||||
|
|
||||||
|
private func observeArtworkChanges() {
|
||||||
|
artworkCacheCancellable = MusicManager.shared.$albumArt
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] newArt in
|
||||||
|
guard let self else { return }
|
||||||
|
let artID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)"
|
||||||
|
guard artID != self.cachedArtworkIdentifier else { return }
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
guard let pngData = self.encodeToPNG(newArt) else { return }
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png")
|
||||||
|
try? pngData.write(to: url, options: .atomic)
|
||||||
|
await MainActor.run {
|
||||||
|
self.cachedArtworkPNG = url
|
||||||
|
self.cachedArtworkIdentifier = artID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeVideoArtworkChanges() {
|
||||||
|
videoArtworkCancellable = MusicManager.shared.$videoArtworkURL
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] videoURL in
|
||||||
|
guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return }
|
||||||
|
let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)"
|
||||||
|
self.scheduleLiveWallpaperPreparation(
|
||||||
|
for: videoURL,
|
||||||
|
artwork: MusicManager.shared.albumArt,
|
||||||
|
identifier: artworkID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func encodeToPNG(_ image: NSImage) -> Data? {
|
||||||
|
guard let tiffData = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiffData)
|
||||||
|
else { return nil }
|
||||||
|
return bitmap.representation(using: .png, properties: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) {
|
||||||
|
liveWallpaperTask?.cancel()
|
||||||
|
liveWallpaperTask = nil
|
||||||
|
|
||||||
|
guard isLiveWallpaperAllowed else { return }
|
||||||
|
guard let artworkFileURL else { return }
|
||||||
|
|
||||||
|
let nextFingerprint = videoURL?.absoluteString
|
||||||
|
if activeLiveWallpaperFingerprint != nil, activeLiveWallpaperFingerprint != nextFingerprint {
|
||||||
|
activeLiveWallpaperFingerprint = nil
|
||||||
|
if applyArtworkToPlist(imageURL: artworkFileURL) {
|
||||||
|
restartWallpaperAgent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let videoURL else { return }
|
||||||
|
if activeLiveWallpaperFingerprint == nextFingerprint { return }
|
||||||
|
if let screen = NSScreen.main {
|
||||||
|
showVideoWindow(on: screen, videoURL: videoURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let assetID = customLiveWallpaperAssetID
|
||||||
|
let manifestURL = aerialManifestURL
|
||||||
|
let videosDirectoryURL = aerialVideosDirectoryURL
|
||||||
|
let thumbnailsDirectoryURL = aerialThumbnailsDirectoryURL
|
||||||
|
let manifestBackupURL = liveWallpaperManifestBackupURL
|
||||||
|
let videoBackupURL = liveWallpaperVideoBackupURL
|
||||||
|
let thumbnailBackupURL = liveWallpaperThumbnailBackupURL
|
||||||
|
let thumbnailData = encodeToPNG(artwork)
|
||||||
|
let title = activeSongTitle ?? MusicManager.shared.songTitle
|
||||||
|
let artist = activeArtist ?? MusicManager.shared.artistName
|
||||||
|
let displayName = [title, artist]
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: " - ")
|
||||||
|
|
||||||
|
liveWallpaperTask = Task(priority: .utility) { [weak self] in
|
||||||
|
let prepared = await Self.prepareCustomLiveWallpaperAsset(
|
||||||
|
from: videoURL,
|
||||||
|
assetID: assetID,
|
||||||
|
displayName: displayName.isEmpty ? "Atoll Canvas" : displayName,
|
||||||
|
manifestURL: manifestURL,
|
||||||
|
videosDirectoryURL: videosDirectoryURL,
|
||||||
|
thumbnailsDirectoryURL: thumbnailsDirectoryURL,
|
||||||
|
thumbnailData: thumbnailData,
|
||||||
|
manifestBackupURL: manifestBackupURL,
|
||||||
|
videoBackupURL: videoBackupURL,
|
||||||
|
thumbnailBackupURL: thumbnailBackupURL
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return }
|
||||||
|
let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)"
|
||||||
|
guard currentIdentifier == identifier else { return }
|
||||||
|
guard prepared else { return }
|
||||||
|
guard self.applyAerialToPlist(assetID: assetID) else { return }
|
||||||
|
|
||||||
|
self.activeLiveWallpaperFingerprint = nextFingerprint
|
||||||
|
self.hideVideoWindow()
|
||||||
|
self.restartWallpaperAgent()
|
||||||
|
print("[FullScreenArtworkWindowManager] Live wallpaper applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func prepareCustomLiveWallpaperAsset(
|
||||||
|
from sourceURL: URL,
|
||||||
|
assetID: String,
|
||||||
|
displayName: String,
|
||||||
|
manifestURL: URL,
|
||||||
|
videosDirectoryURL: URL,
|
||||||
|
thumbnailsDirectoryURL: URL,
|
||||||
|
thumbnailData: Data?,
|
||||||
|
manifestBackupURL: URL,
|
||||||
|
videoBackupURL: URL,
|
||||||
|
thumbnailBackupURL: URL
|
||||||
|
) async -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let videoDestinationURL = videosDirectoryURL.appendingPathComponent("\(assetID).mov")
|
||||||
|
let thumbnailDestinationURL = thumbnailsDirectoryURL.appendingPathComponent("\(assetID).png")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fm.createDirectory(at: videosDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
try fm.createDirectory(at: thumbnailsDirectoryURL, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
backupItemIfNeeded(at: manifestURL, backupURL: manifestBackupURL)
|
||||||
|
backupItemIfNeeded(at: videoDestinationURL, backupURL: videoBackupURL)
|
||||||
|
backupItemIfNeeded(at: thumbnailDestinationURL, backupURL: thumbnailBackupURL)
|
||||||
|
|
||||||
|
guard await materializeVideo(from: sourceURL, to: videoDestinationURL) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let thumbnailData {
|
||||||
|
try? thumbnailData.write(to: thumbnailDestinationURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateAerialManifest(
|
||||||
|
manifestURL: manifestURL,
|
||||||
|
assetID: assetID,
|
||||||
|
videoURL: videoDestinationURL,
|
||||||
|
thumbnailURL: fm.fileExists(atPath: thumbnailDestinationURL.path) ? thumbnailDestinationURL : nil,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func materializeVideo(from sourceURL: URL, to destinationURL: URL) async -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
try? fm.removeItem(at: destinationURL)
|
||||||
|
|
||||||
|
if !sourceURL.isFileURL {
|
||||||
|
guard let downloadedURL = await downloadRemoteVideo(from: sourceURL) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer { try? fm.removeItem(at: downloadedURL) }
|
||||||
|
return await materializeVideo(from: downloadedURL, to: destinationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceURL.isFileURL, sourceURL.pathExtension.lowercased() == "mov" {
|
||||||
|
do {
|
||||||
|
try fm.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
return validateVideo(at: destinationURL)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset = AVURLAsset(url: sourceURL)
|
||||||
|
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
|
||||||
|
for preset in presets {
|
||||||
|
try? fm.removeItem(at: destinationURL)
|
||||||
|
guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSession.outputURL = destinationURL
|
||||||
|
exportSession.outputFileType = .mov
|
||||||
|
exportSession.shouldOptimizeForNetworkUse = true
|
||||||
|
|
||||||
|
let exported = await export(exportSession)
|
||||||
|
if exported, validateVideo(at: destinationURL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func downloadRemoteVideo(from sourceURL: URL) async -> URL? {
|
||||||
|
do {
|
||||||
|
let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(httpResponse.statusCode)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension(sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension)
|
||||||
|
try? FileManager.default.removeItem(at: destinationURL)
|
||||||
|
try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
|
||||||
|
return destinationURL
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func export(_ exportSession: AVAssetExportSession) async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
exportSession.exportAsynchronously {
|
||||||
|
continuation.resume(returning: exportSession.status == .completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func validateVideo(at url: URL) -> Bool {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return false }
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
return !asset.tracks(withMediaType: .video).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func updateAerialManifest(
|
||||||
|
manifestURL: URL,
|
||||||
|
assetID: String,
|
||||||
|
videoURL: URL,
|
||||||
|
thumbnailURL: URL?,
|
||||||
|
displayName: String
|
||||||
|
) -> Bool {
|
||||||
|
guard let manifestData = try? Data(contentsOf: manifestURL),
|
||||||
|
var root = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
|
||||||
|
var assets = root["assets"] as? [[String: Any]]
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let existingIndex = assets.firstIndex(where: { ($0["id"] as? String) == assetID }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var customAsset = assets[existingIndex]
|
||||||
|
customAsset["accessibilityLabel"] = displayName
|
||||||
|
customAsset["previewImage"] = thumbnailURL?.absoluteString ?? ""
|
||||||
|
customAsset["url-4K-SDR-240FPS"] = videoURL.absoluteString
|
||||||
|
customAsset["pointsOfInterest"] = [:]
|
||||||
|
assets[existingIndex] = customAsset
|
||||||
|
|
||||||
|
root["assets"] = assets
|
||||||
|
|
||||||
|
guard let encoded = try? JSONSerialization.data(withJSONObject: root, options: []) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try encoded.write(to: manifestURL, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func backupItemIfNeeded(at sourceURL: URL, backupURL: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: sourceURL.path) else { return }
|
||||||
|
guard !fm.fileExists(atPath: backupURL.path) else { return }
|
||||||
|
try? fm.copyItem(at: sourceURL, to: backupURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreLiveWallpaperResources() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let assetID = customLiveWallpaperAssetID
|
||||||
|
let videoDestinationURL = aerialVideosDirectoryURL.appendingPathComponent("\(assetID).mov")
|
||||||
|
let thumbnailDestinationURL = aerialThumbnailsDirectoryURL.appendingPathComponent("\(assetID).png")
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: liveWallpaperManifestBackupURL.path) {
|
||||||
|
try? fm.removeItem(at: aerialManifestURL)
|
||||||
|
try? fm.copyItem(at: liveWallpaperManifestBackupURL, to: aerialManifestURL)
|
||||||
|
try? fm.removeItem(at: liveWallpaperManifestBackupURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: liveWallpaperVideoBackupURL.path) {
|
||||||
|
try? fm.removeItem(at: videoDestinationURL)
|
||||||
|
try? fm.copyItem(at: liveWallpaperVideoBackupURL, to: videoDestinationURL)
|
||||||
|
try? fm.removeItem(at: liveWallpaperVideoBackupURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: liveWallpaperThumbnailBackupURL.path) {
|
||||||
|
try? fm.removeItem(at: thumbnailDestinationURL)
|
||||||
|
try? fm.copyItem(at: liveWallpaperThumbnailBackupURL, to: thumbnailDestinationURL)
|
||||||
|
try? fm.removeItem(at: liveWallpaperThumbnailBackupURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Click Receiver
|
||||||
|
|
||||||
|
private func showClickReceiver(on screen: NSScreen) {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
|
||||||
|
let window: ClickReceiverWindow
|
||||||
|
if let existing = clickWindow {
|
||||||
|
window = existing
|
||||||
|
} else {
|
||||||
|
let newWindow = ClickReceiverWindow(
|
||||||
|
contentRect: screenFrame,
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
newWindow.isReleasedWhenClosed = false
|
||||||
|
newWindow.isOpaque = false
|
||||||
|
newWindow.backgroundColor = .clear
|
||||||
|
newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||||
|
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||||
|
newWindow.isMovable = false
|
||||||
|
newWindow.hasShadow = false
|
||||||
|
|
||||||
|
ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface)
|
||||||
|
clickWindow = newWindow
|
||||||
|
window = newWindow
|
||||||
|
clickWindowDelegated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setFrame(screenFrame, display: true)
|
||||||
|
window.onClick = { [weak self] in
|
||||||
|
self?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !clickWindowDelegated {
|
||||||
|
SkyLightOperator.shared.delegateWindow(window)
|
||||||
|
clickWindowDelegated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
|
||||||
|
for other in NSApp.windows where other !== window && other.level.rawValue >= Int(CGShieldingWindowLevel()) && other.isVisible {
|
||||||
|
window.order(.below, relativeTo: other.windowNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideClickReceiver() {
|
||||||
|
clickWindow?.orderOut(nil)
|
||||||
|
clickWindow?.onClick = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showVideoWindow(on screen: NSScreen, videoURL: URL) {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
|
||||||
|
let window: NSWindow
|
||||||
|
let view: LoopingVideoView
|
||||||
|
|
||||||
|
if let existingWindow = videoWindow, let existingView = videoView {
|
||||||
|
window = existingWindow
|
||||||
|
view = existingView
|
||||||
|
} else {
|
||||||
|
let newWindow = NSWindow(
|
||||||
|
contentRect: screenFrame,
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
newWindow.isReleasedWhenClosed = false
|
||||||
|
newWindow.isOpaque = false
|
||||||
|
newWindow.backgroundColor = .clear
|
||||||
|
newWindow.ignoresMouseEvents = true
|
||||||
|
newWindow.hasShadow = false
|
||||||
|
newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)
|
||||||
|
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||||
|
|
||||||
|
let newView = LoopingVideoView(frame: screenFrame)
|
||||||
|
newWindow.contentView = newView
|
||||||
|
|
||||||
|
videoWindow = newWindow
|
||||||
|
videoView = newView
|
||||||
|
window = newWindow
|
||||||
|
view = newView
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setFrame(screenFrame, display: true)
|
||||||
|
view.frame = NSRect(origin: .zero, size: screenFrame.size)
|
||||||
|
view.play(url: videoURL)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideVideoWindow() {
|
||||||
|
videoView?.stop()
|
||||||
|
videoWindow?.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backup / Restore
|
||||||
|
|
||||||
|
private var hasValidBackup: Bool {
|
||||||
|
FileManager.default.fileExists(atPath: backupPlistURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backupWallpaperConfig() {
|
||||||
|
guard !hasValidBackup else { return }
|
||||||
|
try? FileManager.default.copyItem(at: wallpaperPlistURL, to: backupPlistURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyArtworkToPlist(imageURL: URL) -> Bool {
|
||||||
|
guard let plistData = try? Data(contentsOf: wallpaperPlistURL),
|
||||||
|
var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any]
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let desktopBlock = makeImageWallpaperBlock(imageURL: imageURL)
|
||||||
|
let idleBlock = makeImageWallpaperBlock(imageURL: imageURL)
|
||||||
|
|
||||||
|
patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock)
|
||||||
|
|
||||||
|
guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAerialToPlist(assetID: String) -> Bool {
|
||||||
|
guard let plistData = try? Data(contentsOf: wallpaperPlistURL),
|
||||||
|
var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any]
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let desktopTemplate = wallpaperBlock(
|
||||||
|
named: "Desktop",
|
||||||
|
in: plist["AllSpacesAndDisplays"] as? [String: Any],
|
||||||
|
fallback: plist["SystemDefault"] as? [String: Any]
|
||||||
|
)
|
||||||
|
let idleTemplate = wallpaperBlock(
|
||||||
|
named: "Idle",
|
||||||
|
in: plist["AllSpacesAndDisplays"] as? [String: Any],
|
||||||
|
fallback: plist["SystemDefault"] as? [String: Any]
|
||||||
|
)
|
||||||
|
|
||||||
|
let desktopBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: desktopTemplate)
|
||||||
|
let idleBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: idleTemplate)
|
||||||
|
|
||||||
|
patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock)
|
||||||
|
|
||||||
|
guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wallpaperBlock(named name: String, in primary: [String: Any]?, fallback: [String: Any]?) -> [String: Any]? {
|
||||||
|
if let primaryBlock = primary?[name] as? [String: Any] {
|
||||||
|
return primaryBlock
|
||||||
|
}
|
||||||
|
return fallback?[name] as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeImageWallpaperBlock(imageURL: URL) -> [String: Any] {
|
||||||
|
let config: [String: Any] = [
|
||||||
|
"type": "imageFile",
|
||||||
|
"url": ["relative": imageURL.absoluteString]
|
||||||
|
]
|
||||||
|
let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data()
|
||||||
|
|
||||||
|
let imageChoice: [String: Any] = [
|
||||||
|
"Provider": "com.apple.wallpaper.choice.image",
|
||||||
|
"Files": [] as [Any],
|
||||||
|
"Configuration": configData
|
||||||
|
]
|
||||||
|
|
||||||
|
let contentBlock: [String: Any] = [
|
||||||
|
"Choices": [imageChoice],
|
||||||
|
"Shuffle": "$null"
|
||||||
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Content": contentBlock,
|
||||||
|
"LastSet": Date(),
|
||||||
|
"LastUse": Date()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAerialWallpaperBlock(assetID: String, existingBlock: [String: Any]?) -> [String: Any] {
|
||||||
|
let config: [String: Any] = [
|
||||||
|
"assetID": assetID
|
||||||
|
]
|
||||||
|
let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data()
|
||||||
|
|
||||||
|
let aerialChoice: [String: Any] = [
|
||||||
|
"Provider": "com.apple.wallpaper.choice.aerials",
|
||||||
|
"Files": [] as [Any],
|
||||||
|
"Configuration": configData
|
||||||
|
]
|
||||||
|
|
||||||
|
var contentBlock: [String: Any] = [
|
||||||
|
"Choices": [aerialChoice],
|
||||||
|
"Shuffle": "$null"
|
||||||
|
]
|
||||||
|
|
||||||
|
if let existingContent = existingBlock?["Content"] as? [String: Any],
|
||||||
|
let encodedOptionValues = existingContent["EncodedOptionValues"] {
|
||||||
|
contentBlock["EncodedOptionValues"] = encodedOptionValues
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Content": contentBlock,
|
||||||
|
"LastSet": Date(),
|
||||||
|
"LastUse": Date()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func patchWallpaperEntries(_ plist: inout [String: Any], desktopBlock: [String: Any], idleBlock: [String: Any]) {
|
||||||
|
var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:]
|
||||||
|
allSpaces["Desktop"] = desktopBlock
|
||||||
|
allSpaces["Idle"] = idleBlock
|
||||||
|
allSpaces["Type"] = allSpaces["Type"] ?? "individual"
|
||||||
|
plist["AllSpacesAndDisplays"] = allSpaces
|
||||||
|
|
||||||
|
var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:]
|
||||||
|
systemDefault["Desktop"] = desktopBlock
|
||||||
|
systemDefault["Idle"] = idleBlock
|
||||||
|
systemDefault["Type"] = systemDefault["Type"] ?? "individual"
|
||||||
|
plist["SystemDefault"] = systemDefault
|
||||||
|
|
||||||
|
if var displays = plist["Displays"] as? [String: Any] {
|
||||||
|
for key in displays.keys {
|
||||||
|
if var display = displays[key] as? [String: Any] {
|
||||||
|
display["Desktop"] = desktopBlock
|
||||||
|
display["Idle"] = idleBlock
|
||||||
|
displays[key] = display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plist["Displays"] = displays
|
||||||
|
}
|
||||||
|
|
||||||
|
if var spaces = plist["Spaces"] as? [String: Any] {
|
||||||
|
for spaceKey in spaces.keys {
|
||||||
|
if var space = spaces[spaceKey] as? [String: Any] {
|
||||||
|
if var defaultEntry = space["Default"] as? [String: Any] {
|
||||||
|
defaultEntry["Desktop"] = desktopBlock
|
||||||
|
defaultEntry["Idle"] = idleBlock
|
||||||
|
space["Default"] = defaultEntry
|
||||||
|
}
|
||||||
|
if var spaceDisplays = space["Displays"] as? [String: Any] {
|
||||||
|
for displayKey in spaceDisplays.keys {
|
||||||
|
if var display = spaceDisplays[displayKey] as? [String: Any] {
|
||||||
|
display["Desktop"] = desktopBlock
|
||||||
|
display["Idle"] = idleBlock
|
||||||
|
spaceDisplays[displayKey] = display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
space["Displays"] = spaceDisplays
|
||||||
|
}
|
||||||
|
spaces[spaceKey] = space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plist["Spaces"] = spaces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartWallpaperAgent() {
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
|
||||||
|
task.arguments = ["WallpaperAgent"]
|
||||||
|
try? task.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreWallpaper() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard hasValidBackup else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fm.removeItem(at: wallpaperPlistURL)
|
||||||
|
try fm.copyItem(at: backupPlistURL, to: wallpaperPlistURL)
|
||||||
|
try fm.removeItem(at: backupPlistURL)
|
||||||
|
} catch {
|
||||||
|
print("[FullScreenArtworkWindowManager] Failed to restore plist: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreLiveWallpaperResources()
|
||||||
|
restartWallpaperAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Track Change Observer
|
||||||
|
|
||||||
|
private func observeTrackChanges() {
|
||||||
|
trackChangeCancellable?.cancel()
|
||||||
|
trackChangeCancellable = MusicManager.shared.$songTitle
|
||||||
|
.combineLatest(MusicManager.shared.$artistName)
|
||||||
|
.dropFirst()
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] title, artist in
|
||||||
|
guard let self, self.isShowing else { return }
|
||||||
|
if title != self.activeSongTitle || artist != self.activeArtist {
|
||||||
|
self.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,7 @@ class LockScreenManager: ObservableObject {
|
||||||
// Hide panel window immediately and synchronously
|
// Hide panel window immediately and synchronously
|
||||||
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
|
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
|
||||||
LockScreenPanelManager.shared.hidePanel()
|
LockScreenPanelManager.shared.hidePanel()
|
||||||
|
FullScreenArtworkWindowManager.shared.hide()
|
||||||
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
|
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
|
||||||
LockScreenWeatherManager.shared.hideWeatherWidget()
|
LockScreenWeatherManager.shared.hideWeatherWidget()
|
||||||
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
|
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
|
||||||
|
|
|
||||||
|
|
@ -478,7 +478,9 @@ class MusicManager: ObservableObject {
|
||||||
private var explicitLookupTask: Task<Void, Never>?
|
private var explicitLookupTask: Task<Void, Never>?
|
||||||
private var explicitLookupKey: String?
|
private var explicitLookupKey: String?
|
||||||
|
|
||||||
private var artworkData: Data? = nil
|
private(set) var artworkData: Data? = nil
|
||||||
|
|
||||||
|
@Published var videoArtworkURL: URL? = nil
|
||||||
|
|
||||||
private var liveStreamUnknownDurationCount: Int = 0
|
private var liveStreamUnknownDurationCount: Int = 0
|
||||||
private var liveStreamEdgeObservationCount: Int = 0
|
private var liveStreamEdgeObservationCount: Int = 0
|
||||||
|
|
@ -709,6 +711,11 @@ class MusicManager: ObservableObject {
|
||||||
|| bundleChanged
|
|| bundleChanged
|
||||||
|| contentIdentifierChanged
|
|| contentIdentifierChanged
|
||||||
|| contentURLChanged
|
|| contentURLChanged
|
||||||
|
let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL
|
||||||
|
|
||||||
|
if liveArtworkChanged {
|
||||||
|
self.videoArtworkURL = state.liveArtworkURL
|
||||||
|
}
|
||||||
|
|
||||||
// Handle artwork and visual transitions for changed content
|
// Handle artwork and visual transitions for changed content
|
||||||
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
|
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
|
||||||
|
|
@ -735,8 +742,13 @@ class MusicManager: ObservableObject {
|
||||||
self.lastArtworkContentIdentifier = state.contentIdentifier
|
self.lastArtworkContentIdentifier = state.contentIdentifier
|
||||||
self.lastArtworkContentURL = state.contentURL
|
self.lastArtworkContentURL = state.contentURL
|
||||||
|
|
||||||
// Fetch lyrics for new track whenever content changes
|
|
||||||
self.fetchLyrics()
|
self.fetchLyrics()
|
||||||
|
if let liveArtworkURL = state.liveArtworkURL {
|
||||||
|
self.videoArtworkURL = liveArtworkURL
|
||||||
|
} else {
|
||||||
|
self.fetchVideoArtwork()
|
||||||
|
}
|
||||||
|
|
||||||
self.refreshExplicitFlag(for: state)
|
self.refreshExplicitFlag(for: state)
|
||||||
|
|
||||||
// Only update sneak peek if there's actual content and something changed
|
// Only update sneak peek if there's actual content and something changed
|
||||||
|
|
@ -1424,6 +1436,76 @@ class MusicManager: ObservableObject {
|
||||||
lyricSyncTask = nil
|
lyricSyncTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Video Artwork
|
||||||
|
|
||||||
|
func fetchVideoArtwork() {
|
||||||
|
guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else {
|
||||||
|
videoArtworkURL = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard bundleIdentifier == "com.apple.Music" else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = songTitle
|
||||||
|
let artist = artistName
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let url = await fetchAnimatedArtworkURL(title: title, artist: artist)
|
||||||
|
await MainActor.run {
|
||||||
|
self.videoArtworkURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? {
|
||||||
|
guard !title.isEmpty, !artist.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let query = "\(title) \(artist)"
|
||||||
|
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=musicVideo&limit=5")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: searchURL)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let results = json["results"] as? [[String: Any]],
|
||||||
|
let first = results.first,
|
||||||
|
let previewURLString = first["previewUrl"] as? String,
|
||||||
|
let previewURL = URL(string: previewURLString)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return previewURL
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHighResArtworkURL() async -> URL? {
|
||||||
|
let title = songTitle
|
||||||
|
let artist = artistName
|
||||||
|
guard !title.isEmpty, !artist.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let query = "\(title) \(artist)"
|
||||||
|
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=5")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: searchURL)
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let results = json["results"] as? [[String: Any]],
|
||||||
|
let first = results.first,
|
||||||
|
let artworkURLString = first["artworkUrl100"] as? String
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let highRes = artworkURLString.replacingOccurrences(of: "100x100", with: "3000x3000")
|
||||||
|
return URL(string: highRes)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toggleLyrics() {
|
func toggleLyrics() {
|
||||||
// Toggle the UI state first so the views can react immediately.
|
// Toggle the UI state first so the views can react immediately.
|
||||||
showLyrics.toggle()
|
showLyrics.toggle()
|
||||||
|
|
|
||||||
343
DynamicIsland/managers/SpotifyAuthManager.swift
Normal file
343
DynamicIsland/managers/SpotifyAuthManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -910,6 +910,8 @@ extension Defaults.Keys {
|
||||||
)
|
)
|
||||||
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
||||||
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
|
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
|
||||||
|
static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true)
|
||||||
|
static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true)
|
||||||
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
|
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
|
||||||
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
||||||
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
|
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
|
||||||
|
|
@ -988,6 +990,10 @@ extension Defaults.Keys {
|
||||||
|
|
||||||
// MARK: Media Controller
|
// MARK: Media Controller
|
||||||
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
|
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
|
||||||
|
static let spotifySPDCCookie = Key<String>("spotifySPDCCookie", default: "")
|
||||||
|
static let spotifyAuthAccessToken = Key<String>("spotifyAuthAccessToken", default: "")
|
||||||
|
static let spotifyAuthAccessTokenExpiration = Key<Double>("spotifyAuthAccessTokenExpiration", default: 0)
|
||||||
|
static let spotifyAuthLastValidatedAt = Key<Double>("spotifyAuthLastValidatedAt", default: 0)
|
||||||
|
|
||||||
// MARK: Bluetooth Audio Devices
|
// MARK: Bluetooth Audio Devices
|
||||||
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
|
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ struct PlaybackState {
|
||||||
var repeatMode: RepeatMode = .off
|
var repeatMode: RepeatMode = .off
|
||||||
var lastUpdated: Date = Date.distantPast
|
var lastUpdated: Date = Date.distantPast
|
||||||
var artwork: Data?
|
var artwork: Data?
|
||||||
|
var liveArtworkURL: URL?
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PlaybackState: Equatable {
|
extension PlaybackState: Equatable {
|
||||||
|
|
@ -61,5 +62,6 @@ extension PlaybackState: Equatable {
|
||||||
&& lhs.isShuffled == rhs.isShuffled
|
&& lhs.isShuffled == rhs.isShuffled
|
||||||
&& lhs.repeatMode == rhs.repeatMode
|
&& lhs.repeatMode == rhs.repeatMode
|
||||||
&& lhs.artwork == rhs.artwork
|
&& lhs.artwork == rhs.artwork
|
||||||
|
&& lhs.liveArtworkURL == rhs.liveArtworkURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue