Fix: Migrations

This commit is contained in:
Hariharan Mudaliar 2025-08-03 21:26:50 +05:30
commit be152c1531
41 changed files with 2373 additions and 1321 deletions

View file

@ -2,7 +2,7 @@
// AppleMusicController.swift
// DynamicIsland
//
// Created by Alexander Greco on 2025-03-29.
// Created by Alexander on 2025-03-29.
//
import Foundation
@ -15,110 +15,125 @@ class AppleMusicController: MediaControllerProtocol {
bundleIdentifier: "com.apple.Music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // AppleMusic controller always works
}
private var notificationTask: Task<Void, Never>?
// MARK: - Initialization
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.apple.Music.playerInfo")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
deinit {
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
notificationTask?.cancel()
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
func play() async {
await executeCommand("play")
}
func pause() {
executeCommand("pause")
func pause() async {
await executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
func togglePlay() async {
await executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
func nextTrack() async {
await executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
func previousTrack() async {
await executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
func seek(to time: Double) async {
await executeCommand("set player position to \(time)")
await updatePlaybackInfo()
}
func toggleShuffle() async {
await executeCommand("set shuffle enabled to not shuffle enabled")
playbackState.isShuffled.toggle()
}
func toggleRepeat() async {
await executeCommand("""
if song repeat is off then
set song repeat to all
else if song repeat is all then
set song repeat to one
else
set song repeat to off
end if
""")
if playbackState.repeatMode == .off {
playbackState.repeatMode = .all
} else if playbackState.repeatMode == .all {
playbackState.repeatMode = .one
} else {
playbackState.repeatMode = .off
}
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" }
}
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 8 else { return }
var updatedState = self.playbackState
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
updatedState.album = descriptor.atIndex(4)?.stringValue ?? "Unknown"
updatedState.currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
updatedState.duration = descriptor.atIndex(6)?.doubleValue ?? 0
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
updatedState.artwork = descriptor.atIndex(9)?.data as Data?
self.playbackState = updatedState
}
// MARK: - Private Methods
@objc func updatePlaybackInfo() {
guard let descriptor = fetchPlaybackInfo() else { return }
guard descriptor.numberOfItems >= 8 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
let duration = descriptor.atIndex(6)?.doubleValue ?? 0
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkData = descriptor.atIndex(9)?.data as Data?
let updatedState = PlaybackState(
bundleIdentifier: "com.apple.Music",
isPlaying: isPlaying,
title: currentTrack,
artist: currentTrackArtist,
album: currentTrackAlbum,
currentTime: currentTime,
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
isRepeating: isRepeating,
lastUpdated: Date(),
artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData
)
DispatchQueue.main.async { [weak self] in
self?.playbackState = updatedState
}
}
private func executeCommand(_ command: String) {
private func executeCommand(_ command: String) async {
let script = "tell application \"Music\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
try? await AppleScriptHelper.executeVoid(script)
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Music"
set isRunning to true
@ -129,26 +144,28 @@ class AppleMusicController: MediaControllerProtocol {
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to false
set repeatState to false
set shuffleState to shuffle enabled
set repeatState to song repeat
if repeatState is off then
set repeatValue to 0
else if repeatState is all then
set repeatValue to 1
else if repeatState is one then
set repeatValue to 2
end if
try
set artData to data of artwork 1 of current track
on error
set artData to ""
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData}
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""}
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
end try
end tell
"""
var descriptor: NSAppleEventDescriptor? = nil
let semaphore = DispatchSemaphore(value: 0)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
return try await AppleScriptHelper.execute(script)
}
}

View file

@ -6,16 +6,19 @@
//
import Foundation
import AppKit
import Combine
protocol MediaControllerProtocol: ObservableObject {
var playbackStatePublisher: Published<PlaybackState>.Publisher { get }
func play()
func pause()
func seek(to time: Double)
func nextTrack()
func previousTrack()
func togglePlay()
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { get }
var isWorking: Bool { get }
func play() async
func pause() async
func seek(to time: Double) async
func nextTrack() async
func previousTrack() async
func togglePlay() async
func toggleShuffle() async
func toggleRepeat() async
func isActive() -> Bool
func updatePlaybackInfo()
func updatePlaybackInfo() async
}

View file

@ -5,193 +5,222 @@
// Created by Alexander on 2025-03-29.
//
import Combine
import AppKit
import Combine
import Foundation
class NowPlayingController: ObservableObject, MediaControllerProtocol {
final class NowPlayingController: ObservableObject, MediaControllerProtocol {
// Stub for now to conform with ControllerProtocol
func updatePlaybackInfo() {}
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
@Published private(set) var playbackState: PlaybackState = .init(
bundleIdentifier: "com.apple.Music"
)
private var isResourcesAvailable = false
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
private var cancellables = Set<AnyCancellable>()
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
var isWorking: Bool {
return isResourcesAvailable
}
private var lastMusicItem:
(title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
// MARK: - Media Remote Functions
private let mediaRemoteBundle: CFBundle
private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void
private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void
private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void
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 pipe: Pipe?
private var buffer = ""
// MARK: - Initialization
init?() {
guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString),
let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString),
let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString),
let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString),
let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString),
let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString)
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
MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self)
MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self)
MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self)
MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self)
MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
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)
setupNowPlayingObserver()
updatePlaybackInfo()
}
deinit {
// Clean up all Combine subscribers
cancellables.removeAll()
// Remove distributed notification observers
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
if let process = self.process {
if process.isRunning {
process.terminate()
process.waitUntilExit()
}
}
if let pipe = self.pipe {
pipe.fileHandleForReading.closeFile()
pipe.fileHandleForWriting.closeFile()
}
self.process = nil
self.pipe = nil
}
// MARK: - Protocol Implementation
func play() {
func play() async {
MRMediaRemoteSendCommandFunction(0, nil)
}
func pause() {
func pause() async {
MRMediaRemoteSendCommandFunction(1, nil)
}
func togglePlay() {
func togglePlay() async {
MRMediaRemoteSendCommandFunction(2, nil)
}
func nextTrack() {
func nextTrack() async {
MRMediaRemoteSendCommandFunction(4, nil)
}
func previousTrack() {
func previousTrack() async {
MRMediaRemoteSendCommandFunction(5, nil)
}
func seek(to time: Double) {
func seek(to time: Double) async {
MRMediaRemoteSetElapsedTimeFunction(time)
}
func isActive() -> Bool {
return true
}
// MARK: - Setup Methods
private func setupNowPlayingObserver() {
MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"))
.sink { [weak self] _ in self?.updatePlaybackInfo() }
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"))
.sink { [weak self] _ in self?.updateApp() }
.store(in: &cancellables)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
func toggleShuffle() async {
// MRMediaRemoteSendCommandFunction(6, nil)
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1)
playbackState.isShuffled.toggle()
}
// MARK: - Update Methods
@objc func updateApp() {
// Get the now playing client
MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in
guard let clientObj = clientObj else {
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback
}
return
}
// Try to get parent bundle ID first, then fall back to direct bundle ID
var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj)
if appBundleID == nil {
appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj)
}
// Special case for WebKit.GPU which is often Safari
if appBundleID == "com.apple.WebKit.GPU" {
appBundleID = "com.apple.Safari"
}
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music"
}
}
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() {
let process = Process()
guard
let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
else {
print("⚠️ Could not find mediaremote-adapter.pl script or framework path - NowPlaying controller disabled")
isResourcesAvailable = false
return
}
isResourcesAvailable = true
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, "stream"]
let pipe = Pipe()
process.standardOutput = pipe
self.process = process
self.pipe = pipe
@objc func updatePlaybackInfo() {
updateApp()
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in
guard let self = self else { return }
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0
let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0
let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data
let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date()
let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1
// Update playback state
DispatchQueue.main.async {
self.playbackState.title = title
self.playbackState.artist = artist
self.playbackState.album = album
self.playbackState.duration = duration
self.playbackState.currentTime = currentTime
self.playbackState.artwork = artworkData
self.playbackState.lastUpdated = timestamp
self.playbackState.playbackRate = playbackRate
// Check playback state
self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in
DispatchQueue.main.async {
self?.playbackState.isPlaying = isPlaying
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let chunk = String(data: data, encoding: .utf8) {
self?.buffer.append(chunk)
while let range = self?.buffer.range(of: "\n") {
let line = String(self?.buffer[..<range.lowerBound] ?? "")
self?.buffer = String(self?.buffer[range.upperBound...] ?? "")
if !line.isEmpty {
self?.handleAdapterUpdate(line)
}
}
}
}
do {
try process.run()
} catch {
print("⚠️ Failed to launch mediaremote-adapter.pl: \(error)")
}
}
// MARK: - Update Methods
private func handleAdapterUpdate(_ jsonLine: String) {
guard let data = jsonLine.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let payload = object["payload"] as? [String: Any]
else { return }
let diff = object["diff"] as? Bool ?? false
var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier)
newPlaybackState.title = payload["title"] as? String ?? (diff ? self.playbackState.title : "")
newPlaybackState.artist = payload["artist"] as? String ?? (diff ? self.playbackState.artist : "")
newPlaybackState.album = payload["album"] as? String ?? (diff ? self.playbackState.album : "")
newPlaybackState.duration = payload["duration"] as? Double ?? (diff ? self.playbackState.duration : 0)
newPlaybackState.currentTime = payload["elapsedTime"] as? Double ?? (diff ? self.playbackState.currentTime : 0)
if let shuffleMode = payload["shuffleMode"] as? Int {
newPlaybackState.isShuffled = shuffleMode != 1
} else if !diff {
newPlaybackState.isShuffled = false
}
if let repeatModeValue = payload["repeatMode"] as? Int {
newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off
} else if !diff {
newPlaybackState.repeatMode = .off
}
if let artworkDataString = payload["artworkData"] as? String {
newPlaybackState.artwork = Data(
base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else if !diff {
newPlaybackState.artwork = nil
}
if let dateString = payload["timestamp"] as? String,
let date = ISO8601DateFormatter().date(from: dateString) {
newPlaybackState.lastUpdated = date
}
newPlaybackState.playbackRate = payload["playbackRate"] as? Double ?? (diff ? self.playbackState.playbackRate : 1.0)
newPlaybackState.isPlaying = payload["playing"] as? Bool ?? (diff ? self.playbackState.isPlaying : false)
newPlaybackState.bundleIdentifier = (
payload["parentApplicationBundleIdentifier"] as? String ??
payload["bundleIdentifier"] as? String ??
(diff ? self.playbackState.bundleIdentifier : "")
)
self.playbackState = newPlaybackState
}
}

View file

@ -2,7 +2,7 @@
// SpotifyController.swift
// DynamicIsland
//
// Created by Alexander Greco on 2025-03-29.
// Created by Alexander on 2025-03-29.
//
import Foundation
@ -15,59 +15,81 @@ class SpotifyController: MediaControllerProtocol {
bundleIdentifier: "com.spotify.client"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // Spotify controller always works
}
private var notificationTask: Task<Void, Never>?
//Constant for time between command and update
let commandUpdateDelay: Duration = .milliseconds(25)
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
deinit {
// Remove notification observer when controller is deallocated
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
notificationTask?.cancel()
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
func play() async {
await executeCommand("play")
}
func pause() {
executeCommand("pause")
func pause() async {
await executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
func togglePlay() async {
await executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
func nextTrack() async {
await executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
func previousTrack() async {
await executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
func seek(to time: Double) async {
await executeCommand("set player position to \(time)")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func toggleShuffle() async {
await executeCommand("set shuffling to not shuffling")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func toggleRepeat() async {
await executeCommand("set repeating to not repeating")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func isActive() -> Bool {
@ -75,9 +97,8 @@ class SpotifyController: MediaControllerProtocol {
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
// MARK: - Private Methods
@objc func updatePlaybackInfo() {
guard let descriptor = fetchPlaybackInfo() else { return }
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 9 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
@ -100,42 +121,35 @@ class SpotifyController: MediaControllerProtocol {
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
isRepeating: isRepeating,
repeatMode: isRepeating ? .all : .off,
lastUpdated: Date()
)
DispatchQueue.main.async { [weak self] in
self?.playbackState = state
}
self.playbackState = state
// Load artwork asynchronously and update the state when complete
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
DispatchQueue.global(qos: .background).async { [weak self] in
do {
let artworkData = try Data(contentsOf: url)
DispatchQueue.main.async {
// Create a new state with the artwork data and update
var updatedState = state
updatedState.artwork = artworkData
self?.playbackState = updatedState
}
} catch {
print("Failed to load artwork: \(error)")
}
let currentState = state
do {
let (data, _) = try await URLSession.shared.data(from: url)
// Create a new state with the artwork data and update
var updatedState = currentState
updatedState.artwork = data
playbackState = updatedState
} catch {
print("Failed to load artwork: \(error)")
}
} else {
self.playbackState = state
}
}
private func executeCommand(_ command: String) {
// MARK: - Private Methods
private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
try? await AppleScriptHelper.executeVoid(script)
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Spotify"
set isRunning to true
@ -156,13 +170,6 @@ class SpotifyController: MediaControllerProtocol {
end tell
"""
var descriptor: NSAppleEventDescriptor? = nil
let semaphore = DispatchSemaphore(value: 0)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
return try await AppleScriptHelper.execute(script)
}
}

View file

@ -3,6 +3,7 @@
// DynamicIsland
//
// Created By Alexander on 2025-03-30.
// Modified by Pranav on 2025-06-16.
//
import Foundation
@ -12,143 +13,129 @@ import SwiftUI
class YouTubeMusicController: MediaControllerProtocol {
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
bundleIdentifier: "com.github.th-ch.youtube-music",
isPlaying: true
bundleIdentifier: "com.github.th-ch.youtube-music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // YouTube Music controller always works
}
private let baseURL = "http://localhost:26538"
private var accessToken: String?
private var refreshTimer: Timer?
private var periodicUpdateTask: Task<Void, Never>?
private var notificationTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private var playbackInfoCancellable: AnyCancellable?
private var isAuthenticating = false
private let authQueue = DispatchQueue(label: "com.dynamicisland.youtubemusicauth", qos: .background)
init() {
setupAppStateObservers()
authQueue.async { [weak self] in
self?.authenticateAndSetup()
}
Task {await authenticateAndSetup()}
}
deinit {
stopPeriodicUpdates()
notificationTask?.cancel()
cancellables.forEach { $0.cancel() }
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
// MARK: - Authentication
private func authenticateAndSetup() {
private func authenticateAndSetup() async {
// Prevent multiple concurrent authentication attempts
guard !isAuthenticating else { return }
isAuthenticating = true
getAccessToken { [weak self] success in
guard let self = self else { return }
DispatchQueue.main.async {
self.isAuthenticating = false
if success {
self.startPeriodicUpdates()
self.updatePlaybackInfo()
} else {
// Retry authentication after a delay if it fails
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in
self?.authenticateAndSetup()
}
}
}
let success = await getAccessToken()
self.isAuthenticating = false
if success {
await self.startPeriodicUpdates()
await updatePlaybackInfo()
} else {
try? await Task.sleep(for: .seconds(5))
await authenticateAndSetup()
}
}
private func getAccessToken(completion: @escaping (Bool) -> Void) {
guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else {
completion(false)
return
private func getAccessToken() async -> Bool {
guard let url = URL(string: "\(baseURL)/auth/boringNotch") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
URLSession.shared.dataTaskPublisher(for: request)
.map { data, response -> Data in
return data
}
.decode(type: AuthResponse.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completionStatus in
switch completionStatus {
case .failure(_):
completion(false)
case .finished:
break
}
}, receiveValue: { [weak self] response in
self?.accessToken = response.accessToken
completion(true)
})
.store(in: &cancellables)
do {
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(AuthResponse.self, from: data)
accessToken = response.accessToken
return true
} catch {
return false
}
}
// MARK: - Protocol Implementation
func play() {
sendCommand(endpoint: "/play", method: "POST")
updatePlaybackInfo()
func play() async {
await sendCommand(endpoint: "/play", method: "POST")
await updatePlaybackInfo()
}
func pause() {
sendCommand(endpoint: "/pause", method: "POST")
updatePlaybackInfo()
func pause() async {
await sendCommand(endpoint: "/pause", method: "POST")
await updatePlaybackInfo()
}
func togglePlay() {
func togglePlay() async {
if !isActive() {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) {
NSWorkspace.shared.open(url)
}
}
sendCommand(endpoint: "/toggle-play", method: "POST")
updatePlaybackInfo()
await sendCommand(endpoint: "/toggle-play", method: "POST")
await updatePlaybackInfo()
}
func nextTrack() {
sendCommand(endpoint: "/next", method: "POST")
updatePlaybackInfo()
func nextTrack() async {
await sendCommand(endpoint: "/next", method: "POST")
await updatePlaybackInfo()
}
func previousTrack() {
sendCommand(endpoint: "/previous", method: "POST")
updatePlaybackInfo()
func previousTrack() async {
await sendCommand(endpoint: "/previous", method: "POST")
await updatePlaybackInfo()
}
func seek(to time: Double) {
guard let url = URL(string: "\(baseURL)/seek-to") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
authenticateAndSetup()
return
}
// Create the request body with the seconds value
func seek(to time: Double) async {
// Format the seek data payload according to the API schema
let seekData = ["seconds": time]
do {
request.httpBody = try JSONEncoder().encode(seekData)
} catch {
return
}
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
// Convert seek data to JSON
let jsonData = try JSONEncoder().encode(seekData)
guard let url = URL(string: "\(baseURL)/api/v1/seek-to") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
await authenticateAndSetup()
return
}
request.httpBody = jsonData
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
@ -159,22 +146,44 @@ class YouTubeMusicController: MediaControllerProtocol {
throw URLError(.badServerResponse)
}
return data
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
self?.accessToken = nil
self?.authenticateAndSetup()
}
// Update playback info after seeking
try? await Task.sleep(for: .milliseconds(300))
await updatePlaybackInfo()
} catch {
print("Seek error: \(error)")
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
self.accessToken = nil
await self.authenticateAndSetup()
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
}
} catch {
print("Error encoding seek data: \(error)")
return
}
}
func fetchShuffleState() async {
await sendCommand(endpoint: "/shuffle", method: "GET")
}
func toggleShuffle() async {
await sendCommand(endpoint: "/shuffle", method: "POST")
try? await Task.sleep(for: .milliseconds(200))
await fetchShuffleState()
}
func toggleRepeat() async {
await sendCommand(endpoint: "/switch-repeat", method: "POST")
try? await Task.sleep(for: .milliseconds(200))
await fetchRepeatMode()
}
func fetchRepeatMode() async {
await sendCommand(endpoint: "/repeat-mode", method: "GET")
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
@ -183,131 +192,170 @@ class YouTubeMusicController: MediaControllerProtocol {
// MARK: - Private Methods
private func setupAppStateObservers() {
// Register for app launch notifications
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleAppStateChange),
name: NSWorkspace.didLaunchApplicationNotification,
object: nil
)
// Register for app termination notifications
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleAppStateChange),
name: NSWorkspace.didTerminateApplicationNotification,
object: nil
)
notificationTask = Task { @Sendable [weak self] in
await withTaskGroup(of: Void.self) { group in
// Handle app launch notifications
group.addTask {
let launchNotifications = NSWorkspace.shared.notificationCenter.notifications(
named: NSWorkspace.didLaunchApplicationNotification
)
for await notification in launchNotifications {
await self?.handleAppStateChange(notification: notification)
}
}
// Handle app termination notifications
group.addTask {
let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications(
named: NSWorkspace.didTerminateApplicationNotification
)
for await notification in terminateNotifications {
await self?.handleAppStateChange(notification: notification)
}
}
}
}
}
@objc private func handleAppStateChange(notification: Notification) {
private func handleAppStateChange(notification: Notification) async {
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == playbackState.bundleIdentifier else {
return
}
if notification.name == NSWorkspace.didLaunchApplicationNotification {
startPeriodicUpdates()
await startPeriodicUpdates()
} else if notification.name == NSWorkspace.didTerminateApplicationNotification {
// App has terminated - update state and stop timer
stopPeriodicUpdates()
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.playbackState = PlaybackState(
bundleIdentifier: self.playbackState.bundleIdentifier,
isPlaying: false
)
}
self.playbackState = PlaybackState(
bundleIdentifier: self.playbackState.bundleIdentifier,
isPlaying: false
)
}
}
private func startPeriodicUpdates() {
private func startPeriodicUpdates() async {
stopPeriodicUpdates() // Ensure we don't create multiple timers
// Only start the timer if the app is actually running
guard isActive() else { return }
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.updatePlaybackInfo()
periodicUpdateTask = Task { [weak self] in
while !Task.isCancelled {
await self?.updatePlaybackInfo()
try? await Task.sleep(for: .seconds(2))
}
}
refreshTimer?.tolerance = 0.5
// Initial update when timer starts
updatePlaybackInfo()
await updatePlaybackInfo()
await fetchRepeatMode()
await fetchShuffleState()
}
private func stopPeriodicUpdates() {
refreshTimer?.invalidate()
refreshTimer = nil
periodicUpdateTask?.cancel()
periodicUpdateTask = nil
}
private func updateRepeatMode(_ modeString: String?) {
guard let modeString = modeString else { return }
let repeatMode: RepeatMode
switch modeString {
case "NONE":
repeatMode = .off
case "ALL":
repeatMode = .all
case "ONE":
repeatMode = .one
default:
repeatMode = .off
}
if repeatMode != playbackState.repeatMode {
playbackState.repeatMode = repeatMode
}
}
func updatePlaybackInfo() {
func updatePlaybackInfo() async {
if !isActive() {
playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false)
stopPeriodicUpdates()
return
}
guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return }
guard let request = await createAuthenticatedRequest(for: "/api/v1/song") else { return }
let backgroundQueue = DispatchQueue.global(qos: .utility)
backgroundQueue.async { [weak self] in
guard let self = self else { return }
do {
let (data, response) = try await URLSession.shared.data(for: request)
playbackInfoCancellable?.cancel()
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
// Check for authentication errors
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Re-authenticate and retry later
DispatchQueue.main.async { [weak self] in
self?.accessToken = nil
self?.authenticateAndSetup()
}
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
return data
}
.decode(type: PlaybackResponse.self, decoder: JSONDecoder())
.subscribe(on: backgroundQueue)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in
self?.updatePlaybackState(with: response)
})
// Check for authentication errors
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Re-authenticate and retry later
self.accessToken = nil
await authenticateAndSetup()
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
let playbackResponse = try JSONDecoder().decode(PlaybackResponse.self, from: data)
await updatePlaybackState(with: playbackResponse)
} catch {
// Handle error silently for now, as this is called periodically
}
}
private func updatePlaybackState(with response: PlaybackResponse) {
playbackState.isPlaying = !response.isPaused
playbackState.title = response.title
playbackState.artist = response.artist
playbackState.album = response.album ?? ""
playbackState.currentTime = response.elapsedSeconds
playbackState.duration = response.songDuration
playbackState.lastUpdated = Date()
private func updatePlaybackState(with response: PlaybackResponse) async {
var updatedState = self.playbackState
updatedState.isPlaying = !response.isPaused
updatedState.title = response.title
updatedState.artist = response.artist
updatedState.album = response.album ?? ""
updatedState.currentTime = response.elapsedSeconds
updatedState.duration = response.songDuration
updatedState.lastUpdated = Date()
if let isShuffled = response.isShuffled {
updatedState.isShuffled = isShuffled
}
// Load artwork if available
if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) {
DispatchQueue.global(qos: .background).async { [weak self] in
do {
let artworkData = try Data(contentsOf: url)
DispatchQueue.main.async {
self?.playbackState.artwork = artworkData
}
} catch { return }
do {
let artworkData = try Data(contentsOf: url)
playbackState.artwork = artworkData
} catch {
NSLog("Failed to load artwork: \(error)")
updatedState.artwork = nil
self.playbackState = updatedState
return
}
}
self.playbackState = updatedState
}
private func sendCommand(endpoint: String, method: String = "GET") {
func pollPlaybackState() async {
if !isActive() {
return
}
await fetchRepeatMode()
await fetchShuffleState()
await updatePlaybackInfo()
}
private func sendCommand(endpoint: String, method: String = "GET") async {
guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return }
var request = URLRequest(url: url)
@ -316,49 +364,65 @@ class YouTubeMusicController: MediaControllerProtocol {
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
authenticateAndSetup()
await authenticateAndSetup()
return
}
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
return data
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
// Authentication error - token might be expired
self?.accessToken = nil
self?.authenticateAndSetup()
// Try the command again after authentication
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in
self?.sendCommand(endpoint: endpoint, method: method)
}
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
// Handle data based on endpoint
if endpoint == "/shuffle" || endpoint == "/shuffle-status" {
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let shuffleState = json["state"] as? Bool {
playbackState.isShuffled = shuffleState
}
} catch {
print("Error parsing shuffle state: \(error)")
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
} else if endpoint == "/repeat-mode" {
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let mode = json["mode"] as? String {
updateRepeatMode(mode)
}
} catch {
print("Error parsing repeat mode: \(error)")
}
}
// Update playback info for relevant commands
await updatePlaybackInfo()
} catch {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
// Authentication error - token might be expired
accessToken = nil
await authenticateAndSetup()
// Try the command again after authentication
try? await Task.sleep(for: .seconds(2))
await sendCommand(endpoint: endpoint, method: method)
}
}
}
private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? {
private func createAuthenticatedRequest(for endpoint: String) async -> URLRequest? {
guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else {
if accessToken == nil {
// Token is missing, try to authenticate again
authenticateAndSetup()
await authenticateAndSetup()
}
return nil
}
@ -383,4 +447,6 @@ struct PlaybackResponse: Codable {
let elapsedSeconds: Double
let songDuration: Double
let imageSrc: String?
let repeatMode: Int?
let isShuffled: Bool?
}