From 517de364f71831bc4843917636328d5033c57ef0 Mon Sep 17 00:00:00 2001 From: Soham Sharma Date: Mon, 13 Apr 2026 18:52:56 +0800 Subject: [PATCH] Added Amazon Music Support --- DynamicIsland/DynamicIslandApp.swift | 1 + .../AmazonMusicController.swift | 287 ++++++++++++++++++ DynamicIsland/audio/AudioTap.swift | 1 + .../MusicControllerSelectionView.swift | 2 + .../components/Settings/SettingsView.swift | 27 +- DynamicIsland/managers/MusicManager.swift | 7 + DynamicIsland/models/Constants.swift | 3 +- Localizable.xcstrings | 260 ++++++++++++++++ 8 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 DynamicIsland/MediaControllers/AmazonMusicController.swift diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 5cfdf7d..dbce56a 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -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", diff --git a/DynamicIsland/MediaControllers/AmazonMusicController.swift b/DynamicIsland/MediaControllers/AmazonMusicController.swift new file mode 100644 index 0000000..c0c4fe8 --- /dev/null +++ b/DynamicIsland/MediaControllers/AmazonMusicController.swift @@ -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 . + */ + +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.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? + + /// 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 + } +} diff --git a/DynamicIsland/audio/AudioTap.swift b/DynamicIsland/audio/AudioTap.swift index 7d4f2e3..0336b70 100644 --- a/DynamicIsland/audio/AudioTap.swift +++ b/DynamicIsland/audio/AudioTap.swift @@ -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", diff --git a/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift b/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift index b3dc6d0..99b05ae 100644 --- a/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift +++ b/DynamicIsland/components/Onboarding/MusicControllerSelectionView.swift @@ -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.") } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 51c1721..dfb234c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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") } diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index e1bfd36..0eee6de 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -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 diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c15f9ea..e2a3159 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -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("alwaysShowTabs", default: true) static let showMirror = Key("showMirror", default: false) static let mirrorShape = Key("mirrorShape", default: MirrorShapeEnum.rectangle) - static let selectedCameraID = Key("selectedCameraID", default: "") static let settingsIconInNotch = Key("settingsIconInNotch", default: true) static let lightingEffect = Key("lightingEffect", default: true) static let accentColor = Key("accentColor", default: Color.blue) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4d27c5d..cdc5a64 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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": {