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()