mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Added Amazon Music Support
This commit is contained in:
parent
dbdeabdd83
commit
517de364f7
8 changed files with 566 additions and 22 deletions
287
DynamicIsland/MediaControllers/AmazonMusicController.swift
Normal file
287
DynamicIsland/MediaControllers/AmazonMusicController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue