Added Amazon Music Support

This commit is contained in:
Soham Sharma 2026-04-13 18:52:56 +08:00
commit 517de364f7
8 changed files with 566 additions and 22 deletions

View file

@ -184,6 +184,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let targetBundleIDs = [
"com.apple.Music",
"com.spotify.client",
"com.amazon.music",
"com.apple.Safari",
"com.tidal.desktop",
"tv.plex.plexamp",

View file

@ -0,0 +1,287 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* 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 Combine
import Foundation
/// Media Remote stream filtered to `com.amazon.music` only. When another app owns
/// Now Playing, state idles so stale Amazon metadata is not shown.
final class AmazonMusicController: ObservableObject, MediaControllerProtocol {
static let bundleIdentifier = "com.amazon.music"
func updatePlaybackInfo() async {}
@Published private(set) var playbackState: PlaybackState = AmazonMusicController.makeIdlePlaybackState()
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
process != nil && process?.isRunning == true
}
private let mediaRemoteBundle: CFBundle
private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void
private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void
private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void
private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void
private var process: Process?
private var pipeHandler: JSONLinesPipeHandler?
private var streamTask: Task<Void, Never>?
/// True only after a stream line explicitly identified Amazon Music as the now playing source.
private var amazonSessionActive = false
init?() {
guard
let bundle = CFBundleCreate(
kCFAllocatorDefault,
NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetElapsedTime" as CFString),
let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetShuffleMode" as CFString),
let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetRepeatMode" as CFString)
else { return nil }
self.mediaRemoteBundle = bundle
MRMediaRemoteSendCommandFunction = unsafeBitCast(
MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(
MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRMediaRemoteSetShuffleModeFunction = unsafeBitCast(
MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self)
MRMediaRemoteSetRepeatModeFunction = unsafeBitCast(
MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self)
Task { await setupNowPlayingObserver() }
}
deinit {
streamTask?.cancel()
if let pipeHandler = self.pipeHandler {
Task { await pipeHandler.close() }
}
if let process = self.process {
if process.isRunning {
process.terminate()
process.waitUntilExit()
}
}
self.process = nil
self.pipeHandler = nil
}
func play() async {
MRMediaRemoteSendCommandFunction(0, nil)
}
func pause() async {
MRMediaRemoteSendCommandFunction(1, nil)
}
func togglePlay() async {
MRMediaRemoteSendCommandFunction(2, nil)
}
func nextTrack() async {
MRMediaRemoteSendCommandFunction(4, nil)
}
func previousTrack() async {
MRMediaRemoteSendCommandFunction(5, nil)
}
func seek(to time: Double) async {
await MainActor.run {
MRMediaRemoteSetElapsedTimeFunction(time)
}
}
func isActive() -> Bool {
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == Self.bundleIdentifier }
}
func toggleShuffle() async {
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3)
playbackState.isShuffled.toggle()
}
func toggleRepeat() async {
let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1)
playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off
MRMediaRemoteSetRepeatModeFunction(newRepeatMode)
}
private func setupNowPlayingObserver() async {
let process = Process()
guard
let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
let frameworkPath =
Bundle.main.resourceURL?
.appendingPathComponent("MediaRemoteAdapter.framework")
.path
else {
assertionFailure("Could not find mediaremote-adapter.pl script or framework path")
return
}
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, "stream"]
let pipeHandler = JSONLinesPipeHandler()
process.standardOutput = await pipeHandler.getPipe()
let stderrPipe = Pipe()
process.standardError = stderrPipe
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty,
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty
else { return }
print("AmazonMusicController [stderr]: \(message)")
}
self.process = process
self.pipeHandler = pipeHandler
do {
try process.run()
streamTask = Task { [weak self] in
await self?.processJSONStream()
}
} catch {
assertionFailure("Failed to launch mediaremote-adapter.pl: \(error)")
}
}
private func processJSONStream() async {
guard let pipeHandler = self.pipeHandler else { return }
await pipeHandler.readJSONLines(as: NowPlayingUpdate.self) { [weak self] update in
await self?.handleAdapterUpdate(update)
}
}
private static func makeIdlePlaybackState() -> PlaybackState {
var state = PlaybackState(bundleIdentifier: Self.bundleIdentifier)
state.title = "Unknown"
state.artist = "Unknown"
state.album = ""
state.isPlaying = false
state.artwork = nil
state.duration = 0
state.currentTime = 0
state.isShuffled = false
state.repeatMode = .off
state.lastUpdated = Date()
return state
}
private func applyIdleBecauseNonAmazonSource() {
amazonSessionActive = false
playbackState = Self.makeIdlePlaybackState()
}
private func handleAdapterUpdate(_ update: NowPlayingUpdate) async {
let payload = update.payload
let diff = update.diff ?? false
let explicitParent = payload.parentApplicationBundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines)
let explicitBundle = payload.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines)
let explicitSource: String? = {
if let p = explicitParent, !p.isEmpty { return p }
if let b = explicitBundle, !b.isEmpty { return b }
return nil
}()
if let source = explicitSource {
if source != Self.bundleIdentifier {
applyIdleBecauseNonAmazonSource()
return
}
amazonSessionActive = true
} else if !diff {
applyIdleBecauseNonAmazonSource()
return
} else if !amazonSessionActive {
return
}
var newPlaybackState = PlaybackState(bundleIdentifier: Self.bundleIdentifier)
newPlaybackState.title = payload.title ?? (diff ? self.playbackState.title : "")
newPlaybackState.artist = payload.artist ?? (diff ? self.playbackState.artist : "")
newPlaybackState.album = payload.album ?? (diff ? self.playbackState.album : "")
newPlaybackState.duration = payload.duration ?? (diff ? self.playbackState.duration : 0)
newPlaybackState.currentTime = payload.elapsedTime ?? (diff ? self.playbackState.currentTime : 0)
if let shuffleMode = payload.shuffleMode {
newPlaybackState.isShuffled = shuffleMode != 1
} else if !diff {
newPlaybackState.isShuffled = false
} else {
newPlaybackState.isShuffled = self.playbackState.isShuffled
}
if let repeatModeValue = payload.repeatMode {
newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off
} else if !diff {
newPlaybackState.repeatMode = .off
} else {
newPlaybackState.repeatMode = self.playbackState.repeatMode
}
if let artworkDataString = payload.artworkData {
newPlaybackState.artwork = Data(
base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else if !diff {
newPlaybackState.artwork = nil
}
if let dateString = payload.timestamp,
let date = ISO8601DateFormatter().date(from: dateString) {
newPlaybackState.lastUpdated = date
} else if !diff {
newPlaybackState.lastUpdated = Date()
} else {
newPlaybackState.lastUpdated = self.playbackState.lastUpdated
}
newPlaybackState.playbackRate = payload.playbackRate ?? (diff ? self.playbackState.playbackRate : 1.0)
newPlaybackState.isPlaying = payload.playing ?? (diff ? self.playbackState.isPlaying : false)
newPlaybackState.bundleIdentifier = Self.bundleIdentifier
self.playbackState = newPlaybackState
}
}

View file

@ -126,6 +126,7 @@ class AudioTap: NSObject {
private let targetBundleIDs = [
"com.apple.Music",
"com.spotify.client",
"com.amazon.music",
"com.apple.Safari",
"com.tidal.desktop",
"tv.plex.plexamp",

View file

@ -143,6 +143,8 @@ extension MediaControllerType {
return String(localized: "Connects directly to the Apple Music app.")
case .youtubeMusic:
return String(localized: "Requires a third-party client with API plugin enabled.")
case .amazonMusic:
return String(localized: "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek.")
}
}
}

View file

@ -2497,9 +2497,12 @@ struct Media: View {
.foregroundColor(.blue) // Ensures it's visibly a link
}
} else {
Text("'Now Playing' was the only option on previous versions and works with all media apps.")
.foregroundStyle(.secondary)
.font(.caption)
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "'Now Playing' was the only option on previous versions and works with all media apps."))
Text(String(localized: "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek."))
}
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
@ -3830,9 +3833,7 @@ struct LiveActivitiesSettings: View {
struct Appearance: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var webcamManager = WebcamManager.shared
@Default(.mirrorShape) var mirrorShape
@Default(.selectedCameraID) var selectedCameraID
@Default(.sliderColor) var sliderColor
@Default(.useMusicVisualizer) var useMusicVisualizer
@Default(.customVisualizers) var customVisualizers
@ -4239,22 +4240,6 @@ struct Appearance: View {
.tag(MirrorShapeEnum.rectangle)
}
.settingsHighlight(id: highlightID("Mirror shape"))
if webcamManager.cameraAvailable {
Picker("Mirror Camera", selection: $selectedCameraID) {
ForEach(webcamManager.availableCameras, id: \.uniqueID) { device in
Text(device.localizedName)
.tag(device.uniqueID)
}
}
.onChange(of: selectedCameraID) { _, _ in
if Defaults[.showMirror] {
webcamManager.stopSession()
webcamManager.startSession()
}
}
.settingsHighlight(id: highlightID("Mirror Camera"))
}
Defaults.Toggle(key: .showNotHumanFace) {
Text("Idle Animation")
}

View file

@ -259,6 +259,8 @@ class MusicManager: ObservableObject {
newController = SpotifyController()
case .youtubeMusic:
newController = YouTubeMusicController()
case .amazonMusic:
newController = AmazonMusicController()
}
// Set up state observation for the new controller
@ -975,6 +977,8 @@ extension MusicManager {
return appleMusicPink
case .spotify:
return spotifyGreen
case .amazonMusic:
return amazonOrange
case .nowPlaying:
if let bundleIdentifier,
let bundleColor = brandAccentColor(forBundleIdentifier: bundleIdentifier) {
@ -992,6 +996,8 @@ extension MusicManager {
return appleMusicPink
case "com.spotify.client":
return spotifyGreen
case AmazonMusicController.bundleIdentifier:
return amazonOrange
default:
return nil
}
@ -999,6 +1005,7 @@ extension MusicManager {
private static let appleMusicPink = Color(red: 0.999, green: 0.171, blue: 0.331)
private static let spotifyGreen = Color(red: 0.0, green: 0.857, blue: 0.302)
private static let amazonOrange = Color(red: 1.0, green: 0.6, blue: 0.0)
}
// MARK: - Album Art Flip Helper

View file

@ -405,6 +405,7 @@ enum MediaControllerType: String, CaseIterable, Identifiable, Defaults.Serializa
case appleMusic = "Apple Music"
case spotify = "Spotify"
case youtubeMusic = "Youtube Music"
case amazonMusic = "Amazon Music"
var id: String { self.rawValue }
@ -414,6 +415,7 @@ enum MediaControllerType: String, CaseIterable, Identifiable, Defaults.Serializa
case .appleMusic: return String(localized: "Apple Music")
case .spotify: return String(localized: "Spotify")
case .youtubeMusic: return String(localized: "Youtube Music")
case .amazonMusic: return String(localized: "Amazon Music")
}
}
}
@ -794,7 +796,6 @@ extension Defaults.Keys {
//static let alwaysShowTabs = Key<Bool>("alwaysShowTabs", default: true)
static let showMirror = Key<Bool>("showMirror", default: false)
static let mirrorShape = Key<MirrorShapeEnum>("mirrorShape", default: MirrorShapeEnum.rectangle)
static let selectedCameraID = Key<String>("selectedCameraID", default: "")
static let settingsIconInNotch = Key<Bool>("settingsIconInNotch", default: true)
static let lightingEffect = Key<Bool>("lightingEffect", default: true)
static let accentColor = Key<Color>("accentColor", default: Color.blue)

View file

@ -11870,6 +11870,136 @@
}
}
},
"Amazon Music": {
"localizations": {
"ar": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"cs": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"de": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Amazon Music"
}
},
"en-GB": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"es": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"fi": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"fr": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"gu": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"hu": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"it": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"nb": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"pl": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"pt-BR": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ru": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"sv": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ta": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"tr": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"uk": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"zh-Hans": {
"stringUnit": {
"state": "new",
"value": ""
}
}
}
},
"Animation": {
"localizations": {
"ar": {
@ -88866,6 +88996,136 @@
}
}
},
"Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek.": {
"localizations": {
"ar": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"cs": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"de": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek."
}
},
"en-GB": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"es": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"fi": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"fr": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"gu": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"hu": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"it": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"nb": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"pl": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"pt-BR": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ru": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"sv": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"ta": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"tr": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"uk": {
"stringUnit": {
"state": "new",
"value": ""
}
},
"zh-Hans": {
"stringUnit": {
"state": "new",
"value": ""
}
}
}
},
"Utilization": {
"comment": "A label displayed alongside the utilization percentage in the GPU detail view.",
"localizations": {