mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #357 from nightah/fix/applemusic-streaming-artwork
fix(mediacontrollers): apple music artwork for streamed content
This commit is contained in:
commit
c31757e5ff
1 changed files with 114 additions and 81 deletions
|
|
@ -20,9 +20,19 @@
|
||||||
* 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 AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import Foundation
|
||||||
|
|
||||||
|
private struct ITunesSearchResponse: Decodable {
|
||||||
|
let results: [ITunesTrack]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ITunesTrack: Decodable {
|
||||||
|
let trackName: String?
|
||||||
|
let collectionName: String?
|
||||||
|
let artworkUrl100: String?
|
||||||
|
}
|
||||||
|
|
||||||
class AppleMusicController: MediaControllerProtocol {
|
class AppleMusicController: MediaControllerProtocol {
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
@ -30,17 +40,23 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
bundleIdentifier: "com.apple.Music",
|
bundleIdentifier: "com.apple.Music",
|
||||||
playbackRate: 1
|
playbackRate: 1
|
||||||
)
|
)
|
||||||
|
|
||||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||||
$playbackState.eraseToAnyPublisher()
|
$playbackState.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isWorking: Bool {
|
var isWorking: Bool {
|
||||||
return true // AppleMusic controller always works
|
return true // AppleMusic controller always works
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimum byte count for artwork data to be considered valid. Anything
|
||||||
|
/// smaller is likely an empty descriptor or error string, not image data.
|
||||||
|
private static let minimumArtworkSize = 16
|
||||||
|
|
||||||
private var notificationTask: Task<Void, Never>?
|
private var notificationTask: Task<Void, Never>?
|
||||||
|
private var lastCatalogArtworkKey: String?
|
||||||
|
private var cachedCatalogArtwork: Data?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
setupPlaybackStateChangeObserver()
|
setupPlaybackStateChangeObserver()
|
||||||
|
|
@ -122,7 +138,7 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
||||||
guard descriptor.numberOfItems >= 8 else { return }
|
guard descriptor.numberOfItems >= 8 else { return }
|
||||||
var updatedState = self.playbackState
|
var updatedState = self.playbackState
|
||||||
|
|
||||||
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||||
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||||
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||||
|
|
@ -132,91 +148,108 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||||
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
|
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
|
||||||
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
|
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
|
||||||
updatedState.artwork = descriptor.atIndex(9)?.data as Data?
|
|
||||||
|
// AppleScript returns artwork data for library tracks. For streamed
|
||||||
|
// content not in the library it returns an empty descriptor, so we
|
||||||
|
// fall back to the iTunes Search API to fetch artwork by metadata.
|
||||||
|
if let artworkData = descriptor.atIndex(9)?.data as Data?,
|
||||||
|
artworkData.count > Self.minimumArtworkSize {
|
||||||
|
updatedState.artwork = artworkData
|
||||||
|
} else {
|
||||||
|
updatedState.artwork = await fetchArtworkFromCatalog(
|
||||||
|
title: updatedState.title, artist: updatedState.artist, album: updatedState.album
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
updatedState.lastUpdated = Date()
|
updatedState.lastUpdated = Date()
|
||||||
self.playbackState = updatedState
|
self.playbackState = updatedState
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func fetchArtworkFromCatalog(title: String, artist: String, album: String) async -> Data? {
|
||||||
|
let key = "\(title)|\(artist)|\(album)"
|
||||||
|
|
||||||
|
if key == lastCatalogArtworkKey, let cached = cachedCatalogArtwork {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with title + artist only; including album in the query can
|
||||||
|
// confuse the free-text search when names overlap. We validate the
|
||||||
|
// album from the structured response fields instead.
|
||||||
|
let query = "\(title) \(artist)"
|
||||||
|
guard !query.trimmingCharacters(in: .whitespaces).isEmpty,
|
||||||
|
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let url = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=10")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
let response = try JSONDecoder().decode(ITunesSearchResponse.self, from: data)
|
||||||
|
guard !response.results.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let normalizedAlbum = album.lowercased()
|
||||||
|
let normalizedTitle = title.lowercased()
|
||||||
|
|
||||||
|
let match = response.results.first(where: {
|
||||||
|
$0.trackName?.lowercased() == normalizedTitle &&
|
||||||
|
$0.collectionName?.lowercased() == normalizedAlbum
|
||||||
|
}) ?? response.results.first(where: {
|
||||||
|
$0.collectionName?.lowercased() == normalizedAlbum
|
||||||
|
}) ?? response.results.first(where: {
|
||||||
|
$0.trackName?.lowercased() == normalizedTitle
|
||||||
|
}) ?? response.results.first
|
||||||
|
|
||||||
|
guard let artworkURLString = match?.artworkUrl100 else { return nil }
|
||||||
|
|
||||||
|
let highResURL = artworkURLString.replacingOccurrences(of: "100x100", with: "600x600")
|
||||||
|
guard let imageURL = URL(string: highResURL) else { return nil }
|
||||||
|
|
||||||
|
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
|
||||||
|
lastCatalogArtworkKey = key
|
||||||
|
cachedCatalogArtwork = imageData
|
||||||
|
return imageData
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func executeCommand(_ command: String) async {
|
private func executeCommand(_ command: String) async {
|
||||||
let script = "tell application \"Music\" to \(command)"
|
let script = "tell application \"Music\" to \(command)"
|
||||||
try? await AppleScriptHelper.executeVoid(script)
|
try? await AppleScriptHelper.executeVoid(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
||||||
if #available(macOS 26.0, *) {
|
let script = """
|
||||||
let script = """
|
tell application "Music"
|
||||||
tell application "Music"
|
try
|
||||||
|
set playerState to player state is playing
|
||||||
|
set currentTrackName to name of current track
|
||||||
|
set currentTrackArtist to artist of current track
|
||||||
|
set currentTrackAlbum to album of current track
|
||||||
|
set trackPosition to player position
|
||||||
|
set trackDuration to duration of current track
|
||||||
|
set shuffleState to shuffle enabled
|
||||||
|
set repeatState to song repeat
|
||||||
|
if repeatState is off then
|
||||||
|
set repeatValue to 1
|
||||||
|
else if repeatState is one then
|
||||||
|
set repeatValue to 2
|
||||||
|
else if repeatState is all then
|
||||||
|
set repeatValue to 3
|
||||||
|
end if
|
||||||
|
|
||||||
|
set artData to ""
|
||||||
try
|
try
|
||||||
set playerState to player state is playing
|
set artData to raw data of artwork 1 of current track
|
||||||
set currentTrackName to name of current track
|
|
||||||
set currentTrackArtist to artist of current track
|
|
||||||
set currentTrackAlbum to album of current track
|
|
||||||
set trackPosition to player position
|
|
||||||
set trackDuration to duration of current track
|
|
||||||
set shuffleState to shuffle enabled
|
|
||||||
set repeatState to song repeat
|
|
||||||
if repeatState is off then
|
|
||||||
set repeatValue to 1
|
|
||||||
else if repeatState is one then
|
|
||||||
set repeatValue to 2
|
|
||||||
else if repeatState is all then
|
|
||||||
set repeatValue to 3
|
|
||||||
end if
|
|
||||||
|
|
||||||
set artData to ""
|
|
||||||
try
|
|
||||||
set libraryTrack to first track of playlist "Library" whose name is currentTrackName and artist is currentTrackArtist
|
|
||||||
set artData to data of artwork 1 of libraryTrack
|
|
||||||
on error
|
|
||||||
try
|
|
||||||
set artData to data of artwork 1 of current track
|
|
||||||
on error
|
|
||||||
set artData to ""
|
|
||||||
end try
|
|
||||||
end try
|
|
||||||
|
|
||||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
|
||||||
on error
|
|
||||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
|
||||||
end try
|
end try
|
||||||
end tell
|
|
||||||
"""
|
|
||||||
return try await AppleScriptHelper.execute(script)
|
|
||||||
} else {
|
|
||||||
let script = """
|
|
||||||
tell application "Music"
|
|
||||||
set isRunning to true
|
|
||||||
try
|
|
||||||
set playerState to player state is playing
|
|
||||||
set currentTrackName to name of current track
|
|
||||||
set currentTrackArtist to artist of current track
|
|
||||||
set currentTrackAlbum to album of current track
|
|
||||||
set trackPosition to player position
|
|
||||||
set trackDuration to duration of current track
|
|
||||||
set shuffleState to shuffle enabled
|
|
||||||
set repeatState to song repeat
|
|
||||||
if repeatState is off then
|
|
||||||
set repeatValue to 1
|
|
||||||
else if repeatState is one then
|
|
||||||
set repeatValue to 2
|
|
||||||
else if repeatState is all then
|
|
||||||
set repeatValue to 3
|
|
||||||
end if
|
|
||||||
|
|
||||||
try
|
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
||||||
set artData to data of artwork 1 of current track
|
on error
|
||||||
on error
|
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
||||||
set artData to ""
|
end try
|
||||||
end try
|
end tell
|
||||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
"""
|
||||||
on error
|
return try await AppleScriptHelper.execute(script)
|
||||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
|
||||||
end try
|
|
||||||
end tell
|
|
||||||
"""
|
|
||||||
return try await AppleScriptHelper.execute(script)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue