Atoll: Add MusicKit animated artwork fetching for Apple Music

Uses MusicKit MusicCatalogSearchRequest to find the current track, then
fetches editorialVideo.motionTallVideo3x4 HLS URL via MusicDataRequest.
The animated artwork URL is pre-fetched on track change and cached per
song for instant availability.

Adds NSAppleMusicUsageDescription to Info.plist for MusicKit
authorization.

Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
This commit is contained in:
Jis G Jacob 2026-04-16 16:23:10 +05:30 • committed by delli
commit 3afa968236
3 changed files with 154 additions and 49 deletions

View file

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

View file

@ -0,0 +1,149 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import MusicKit
actor AnimatedArtworkManager {
static let shared = AnimatedArtworkManager()
private var cachedSongID: String?
private var cachedVideoURL: URL?
private var cachedKey: String?
func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? {
let key = "\(title)|\(artist)"
if key == cachedKey, let url = cachedVideoURL {
return url
}
guard await requestMusicAuthorization() else { return nil }
guard let songID = await searchSongID(title: title, artist: artist) else {
return nil
}
guard let videoURL = await fetchEditorialVideoURL(songID: songID) else {
return nil
}
cachedKey = key
cachedSongID = songID
cachedVideoURL = videoURL
return videoURL
}
func clearCache() {
cachedKey = nil
cachedSongID = nil
cachedVideoURL = nil
}
// MARK: - MusicKit Authorization
private func requestMusicAuthorization() async -> Bool {
let status = MusicAuthorization.currentStatus
if status == .authorized { return true }
let newStatus = await MusicAuthorization.request()
return newStatus == .authorized
}
// MARK: - Song Search
private func searchSongID(title: String, artist: String) async -> String? {
let term = "\(title) \(artist)"
var request = MusicCatalogSearchRequest(term: term, types: [Song.self])
request.limit = 5
do {
let response = try await request.response()
let normalizedTitle = title.lowercased()
let normalizedArtist = artist.lowercased()
let match = response.songs.first(where: {
$0.title.lowercased() == normalizedTitle &&
$0.artistName.lowercased() == normalizedArtist
}) ?? response.songs.first(where: {
$0.title.lowercased() == normalizedTitle
}) ?? response.songs.first
guard let song = match else { return nil }
return song.id.rawValue
} catch {
print("[AnimatedArtworkManager] Search failed: \(error)")
return nil
}
}
// MARK: - Editorial Video Fetch
private func fetchEditorialVideoURL(songID: String) async -> URL? {
let storefront: String
if let code = try? await MusicDataRequest.currentCountryCode {
storefront = code
} else {
storefront = "us"
}
return await fetchEditorialVideoURLFallback(songID: songID, storefront: storefront)
}
private func fetchEditorialVideoURLFallback(songID: String, storefront: String) async -> URL? {
guard let url = URL(string: "https://api.music.apple.com/v1/catalog/\(storefront)/songs/\(songID)?extend=editorialVideo") else {
return nil
}
do {
let request = MusicDataRequest(urlRequest: URLRequest(url: url))
let response = try await request.response()
guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
let data = json["data"] as? [[String: Any]],
let first = data.first,
let attributes = first["attributes"] as? [String: Any],
let editorialVideo = attributes["editorialVideo"] as? [String: Any]
else { return nil }
if let motionTall = editorialVideo["motionTallVideo3x4"] as? [String: Any],
let videoURLString = motionTall["video"] as? String,
let videoURL = URL(string: videoURLString) {
return videoURL
}
if let motionSquare = editorialVideo["motionSquareVideo1x1"] as? [String: Any],
let videoURLString = motionSquare["video"] as? String,
let videoURL = URL(string: videoURLString) {
return videoURL
}
let hlsPattern = try? NSRegularExpression(pattern: "https://mvod\\.itunes\\.apple\\.com[^\"]+\\.m3u8")
let jsonString = String(data: response.data, encoding: .utf8) ?? ""
let range = NSRange(jsonString.startIndex..., in: jsonString)
if let match = hlsPattern?.firstMatch(in: jsonString, range: range),
let matchRange = Range(match.range, in: jsonString) {
return URL(string: String(jsonString[matchRange]))
}
return nil
} catch {
print("[AnimatedArtworkManager] Editorial video fetch failed: \(error)")
return nil
}
}
}

View file

@ -1451,61 +1451,15 @@ class MusicManager: ObservableObject {
let artist = artistName let artist = artistName
Task { Task {
let url = await fetchAnimatedArtworkURL(title: title, artist: artist) let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL(
title: title, artist: artist
)
await MainActor.run { await MainActor.run {
self.videoArtworkURL = url 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()