mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
363 lines
13 KiB
Swift
363 lines
13 KiB
Swift
/*
|
|
* 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
|
|
|
|
final class NowPlayingController: ObservableObject, MediaControllerProtocol {
|
|
// Stub for now to conform with ControllerProtocol
|
|
func updatePlaybackInfo() async {}
|
|
|
|
// MARK: - Properties
|
|
@Published private(set) var playbackState: PlaybackState = .init(
|
|
bundleIdentifier: "com.apple.Music"
|
|
)
|
|
|
|
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
|
$playbackState.eraseToAnyPublisher()
|
|
}
|
|
|
|
var isWorking: Bool {
|
|
return process != nil && process?.isRunning == true
|
|
}
|
|
private var lastMusicItem:
|
|
(title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
|
|
|
|
// MARK: - Media Remote Functions
|
|
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>?
|
|
|
|
// MARK: - Initialization
|
|
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 }
|
|
|
|
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
|
|
}
|
|
|
|
// MARK: - Protocol Implementation
|
|
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 {
|
|
MRMediaRemoteSetElapsedTimeFunction(time)
|
|
}
|
|
|
|
func isActive() -> Bool {
|
|
return true
|
|
}
|
|
|
|
func toggleShuffle() async {
|
|
// MRMediaRemoteSendCommandFunction(6, nil)
|
|
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3)
|
|
playbackState.isShuffled.toggle()
|
|
}
|
|
|
|
func toggleRepeat() async {
|
|
// MRMediaRemoteSendCommandFunction(7, nil)
|
|
let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1)
|
|
playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off
|
|
MRMediaRemoteSetRepeatModeFunction(newRepeatMode)
|
|
}
|
|
|
|
// MARK: - Setup Methods
|
|
private func setupNowPlayingObserver() async {
|
|
let process = Process()
|
|
guard
|
|
let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
|
|
//let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
|
|
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()
|
|
|
|
// Capture stderr so framework/script errors are logged
|
|
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("NowPlayingController [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)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Async Stream Processing
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Update Methods
|
|
private func handleAdapterUpdate(_ update: NowPlayingUpdate) async {
|
|
let payload = update.payload
|
|
let diff = update.diff ?? false
|
|
|
|
var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.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)
|
|
|
|
// Match boring.notch behavior: if elapsedTime is provided use it,
|
|
// if this update is a diff keep the previous currentTime, otherwise default to 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 = (
|
|
payload.parentApplicationBundleIdentifier ??
|
|
payload.bundleIdentifier ??
|
|
(diff ? self.playbackState.bundleIdentifier : "")
|
|
)
|
|
|
|
self.playbackState = newPlaybackState
|
|
}
|
|
}
|
|
|
|
struct NowPlayingUpdate: Codable {
|
|
let payload: NowPlayingPayload
|
|
let diff: Bool?
|
|
}
|
|
|
|
struct NowPlayingPayload: Codable {
|
|
let title: String?
|
|
let artist: String?
|
|
let album: String?
|
|
let duration: Double?
|
|
let elapsedTime: Double?
|
|
let shuffleMode: Int?
|
|
let repeatMode: Int?
|
|
let artworkData: String?
|
|
let timestamp: String?
|
|
let playbackRate: Double?
|
|
let playing: Bool?
|
|
let parentApplicationBundleIdentifier: String?
|
|
let bundleIdentifier: String?
|
|
}
|
|
|
|
actor JSONLinesPipeHandler {
|
|
private let pipe: Pipe
|
|
private let fileHandle: FileHandle
|
|
private var buffer = ""
|
|
|
|
init() {
|
|
self.pipe = Pipe()
|
|
self.fileHandle = pipe.fileHandleForReading
|
|
}
|
|
|
|
func getPipe() -> Pipe {
|
|
return pipe
|
|
}
|
|
|
|
func readJSONLines<T: Decodable>(as type: T.Type, onLine: @escaping (T) async -> Void) async {
|
|
do {
|
|
try await self.processLines(as: type) { decodedObject in
|
|
await onLine(decodedObject)
|
|
}
|
|
} catch {
|
|
print("Error processing JSON stream: \(error)")
|
|
}
|
|
}
|
|
|
|
private func processLines<T: Decodable>(as type: T.Type, onLine: @escaping (T) async -> Void) async throws {
|
|
while true {
|
|
let data = try await readData()
|
|
guard !data.isEmpty else { break }
|
|
|
|
if let chunk = String(data: data, encoding: .utf8) {
|
|
buffer.append(chunk)
|
|
|
|
while let range = buffer.range(of: "\n") {
|
|
let line = String(buffer[..<range.lowerBound])
|
|
buffer = String(buffer[range.upperBound...])
|
|
|
|
if !line.isEmpty {
|
|
await processJSONLine(line, as: type, onLine: onLine)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processJSONLine<T: Decodable>(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async {
|
|
guard let data = line.data(using: .utf8) else {
|
|
return
|
|
}
|
|
do {
|
|
let decodedObject = try JSONDecoder().decode(T.self, from: data)
|
|
await onLine(decodedObject)
|
|
} catch {
|
|
// Ignore lines that can't be decoded
|
|
}
|
|
}
|
|
|
|
private func readData() async throws -> Data {
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
|
|
fileHandle.readabilityHandler = { handle in
|
|
let data = handle.availableData
|
|
handle.readabilityHandler = nil
|
|
continuation.resume(returning: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func close() async {
|
|
do {
|
|
fileHandle.readabilityHandler = nil
|
|
try fileHandle.close()
|
|
try pipe.fileHandleForWriting.close()
|
|
} catch {
|
|
print("Error closing pipe handler: \(error)")
|
|
}
|
|
}
|
|
}
|