/* * 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 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.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? // 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(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(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[..(_ 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)") } } }