diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist
index 92fe265..181d8a5 100644
--- a/DynamicIsland/Info.plist
+++ b/DynamicIsland/Info.plist
@@ -7,6 +7,8 @@
NSAllowsArbitraryLoads
+ NSAppleMusicUsageDescription
+ Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper.
NSAudioCaptureUsageDescription
Atoll needs audio capture permission to display the real-time music waveform visualizer.
NSScreenCaptureUsageDescription
diff --git a/DynamicIsland/managers/AnimatedArtworkManager.swift b/DynamicIsland/managers/AnimatedArtworkManager.swift
new file mode 100644
index 0000000..2993e63
--- /dev/null
+++ b/DynamicIsland/managers/AnimatedArtworkManager.swift
@@ -0,0 +1,149 @@
+/*
+ * Atoll (DynamicIsland)
+ * Copyright (C) 2024-2026 Atoll Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import Foundation
+import MusicKit
+
+actor AnimatedArtworkManager {
+ static let shared = AnimatedArtworkManager()
+
+ private var cachedSongID: String?
+ private var cachedVideoURL: URL?
+ private var cachedKey: String?
+
+ func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? {
+ let key = "\(title)|\(artist)"
+ if key == cachedKey, let url = cachedVideoURL {
+ return url
+ }
+
+ guard await requestMusicAuthorization() else { return nil }
+
+ guard let songID = await searchSongID(title: title, artist: artist) else {
+ return nil
+ }
+
+ guard let videoURL = await fetchEditorialVideoURL(songID: songID) else {
+ return nil
+ }
+
+ cachedKey = key
+ cachedSongID = songID
+ cachedVideoURL = videoURL
+ return videoURL
+ }
+
+ func clearCache() {
+ cachedKey = nil
+ cachedSongID = nil
+ cachedVideoURL = nil
+ }
+
+ // MARK: - MusicKit Authorization
+
+ private func requestMusicAuthorization() async -> Bool {
+ let status = MusicAuthorization.currentStatus
+ if status == .authorized { return true }
+
+ let newStatus = await MusicAuthorization.request()
+ return newStatus == .authorized
+ }
+
+ // MARK: - Song Search
+
+ private func searchSongID(title: String, artist: String) async -> String? {
+ let term = "\(title) \(artist)"
+ var request = MusicCatalogSearchRequest(term: term, types: [Song.self])
+ request.limit = 5
+
+ do {
+ let response = try await request.response()
+ let normalizedTitle = title.lowercased()
+ let normalizedArtist = artist.lowercased()
+
+ let match = response.songs.first(where: {
+ $0.title.lowercased() == normalizedTitle &&
+ $0.artistName.lowercased() == normalizedArtist
+ }) ?? response.songs.first(where: {
+ $0.title.lowercased() == normalizedTitle
+ }) ?? response.songs.first
+
+ guard let song = match else { return nil }
+ return song.id.rawValue
+ } catch {
+ print("[AnimatedArtworkManager] Search failed: \(error)")
+ return nil
+ }
+ }
+
+ // MARK: - Editorial Video Fetch
+
+ private func fetchEditorialVideoURL(songID: String) async -> URL? {
+ let storefront: String
+ if let code = try? await MusicDataRequest.currentCountryCode {
+ storefront = code
+ } else {
+ storefront = "us"
+ }
+
+ return await fetchEditorialVideoURLFallback(songID: songID, storefront: storefront)
+ }
+
+ private func fetchEditorialVideoURLFallback(songID: String, storefront: String) async -> URL? {
+ guard let url = URL(string: "https://api.music.apple.com/v1/catalog/\(storefront)/songs/\(songID)?extend=editorialVideo") else {
+ return nil
+ }
+
+ do {
+ let request = MusicDataRequest(urlRequest: URLRequest(url: url))
+ let response = try await request.response()
+
+ guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
+ let data = json["data"] as? [[String: Any]],
+ let first = data.first,
+ let attributes = first["attributes"] as? [String: Any],
+ let editorialVideo = attributes["editorialVideo"] as? [String: Any]
+ else { return nil }
+
+ if let motionTall = editorialVideo["motionTallVideo3x4"] as? [String: Any],
+ let videoURLString = motionTall["video"] as? String,
+ let videoURL = URL(string: videoURLString) {
+ return videoURL
+ }
+
+ if let motionSquare = editorialVideo["motionSquareVideo1x1"] as? [String: Any],
+ let videoURLString = motionSquare["video"] as? String,
+ let videoURL = URL(string: videoURLString) {
+ return videoURL
+ }
+
+ let hlsPattern = try? NSRegularExpression(pattern: "https://mvod\\.itunes\\.apple\\.com[^\"]+\\.m3u8")
+ let jsonString = String(data: response.data, encoding: .utf8) ?? ""
+ let range = NSRange(jsonString.startIndex..., in: jsonString)
+ if let match = hlsPattern?.firstMatch(in: jsonString, range: range),
+ let matchRange = Range(match.range, in: jsonString) {
+ return URL(string: String(jsonString[matchRange]))
+ }
+
+ return nil
+ } catch {
+ print("[AnimatedArtworkManager] Editorial video fetch failed: \(error)")
+ return nil
+ }
+ }
+}
diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift
index 9588d1d..8fed3f2 100644
--- a/DynamicIsland/managers/MusicManager.swift
+++ b/DynamicIsland/managers/MusicManager.swift
@@ -1451,61 +1451,15 @@ class MusicManager: ObservableObject {
let artist = artistName
Task {
- let url = await fetchAnimatedArtworkURL(title: title, artist: artist)
+ let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL(
+ title: title, artist: artist
+ )
await MainActor.run {
self.videoArtworkURL = url
}
}
}
- private func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? {
- guard !title.isEmpty, !artist.isEmpty else { return nil }
-
- let query = "\(title) \(artist)"
- guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
- let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=musicVideo&limit=5")
- else { return nil }
-
- do {
- let (data, _) = try await URLSession.shared.data(from: searchURL)
- guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let results = json["results"] as? [[String: Any]],
- let first = results.first,
- let previewURLString = first["previewUrl"] as? String,
- let previewURL = URL(string: previewURLString)
- else { return nil }
-
- return previewURL
- } catch {
- return nil
- }
- }
-
- func fetchHighResArtworkURL() async -> URL? {
- let title = songTitle
- let artist = artistName
- guard !title.isEmpty, !artist.isEmpty else { return nil }
-
- let query = "\(title) \(artist)"
- guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
- let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=5")
- else { return nil }
-
- do {
- let (data, _) = try await URLSession.shared.data(from: searchURL)
- guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let results = json["results"] as? [[String: Any]],
- let first = results.first,
- let artworkURLString = first["artworkUrl100"] as? String
- else { return nil }
-
- let highRes = artworkURLString.replacingOccurrences(of: "100x100", with: "3000x3000")
- return URL(string: highRes)
- } catch {
- return nil
- }
- }
-
func toggleLyrics() {
// Toggle the UI state first so the views can react immediately.
showLyrics.toggle()