mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 07:34:32 +00:00
Fix: Migrations
This commit is contained in:
parent
471fb6723e
commit
be152c1531
41 changed files with 2373 additions and 1321 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue