Restored files from old Mac, force pushing

This commit is contained in:
Hariharan Mudaliar 2025-07-13 20:26:03 +05:30
commit 5040381840
126 changed files with 10431 additions and 67 deletions

View file

@ -0,0 +1,154 @@
//
// AppleMusicController.swift
// boringNotch
//
// Created by Alexander Greco on 2025-03-29.
//
import Foundation
import Combine
import SwiftUI
class AppleMusicController: MediaControllerProtocol {
// MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.apple.Music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
// MARK: - Initialization
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
}
deinit {
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
}
func pause() {
executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
// 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) {
let script = "tell application \"Music\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
let script = """
tell application "Music"
set isRunning to true
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
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
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}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""}
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
}
}

View file

@ -0,0 +1,21 @@
//
// MediaControllerProtocol.swift
// boringNotch
//
// Created by Alexander Greco on 2025-03-29.
//
import Foundation
import AppKit
protocol MediaControllerProtocol: ObservableObject {
var playbackStatePublisher: Published<PlaybackState>.Publisher { get }
func play()
func pause()
func seek(to time: Double)
func nextTrack()
func previousTrack()
func togglePlay()
func isActive() -> Bool
func updatePlaybackInfo()
}

View file

@ -0,0 +1,197 @@
//
// NowPlayingController.swift
// boringNotch
//
// Created by Alexander on 2025-03-29.
//
import Combine
import AppKit
import Foundation
class NowPlayingController: ObservableObject, MediaControllerProtocol {
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
bundleIdentifier: "com.apple.Music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
private var cancellables = Set<AnyCancellable>()
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 MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void
// 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)
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)
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
)
}
// MARK: - Protocol Implementation
func play() {
MRMediaRemoteSendCommandFunction(0, nil)
}
func pause() {
MRMediaRemoteSendCommandFunction(1, nil)
}
func togglePlay() {
MRMediaRemoteSendCommandFunction(2, nil)
}
func nextTrack() {
MRMediaRemoteSendCommandFunction(4, nil)
}
func previousTrack() {
MRMediaRemoteSendCommandFunction(5, nil)
}
func seek(to time: Double) {
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
)
}
// 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"
}
}
}
@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
}
}
}
}
}
}

View file

@ -0,0 +1,168 @@
//
// SpotifyController.swift
// boringNotch
//
// Created by Alexander Greco on 2025-03-29.
//
import Foundation
import Combine
import SwiftUI
class SpotifyController: MediaControllerProtocol {
// MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.spotify.client"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
}
deinit {
// Remove notification observer when controller is deallocated
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
}
func pause() {
executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
// MARK: - Private Methods
@objc func updatePlaybackInfo() {
guard let descriptor = fetchPlaybackInfo() else { return }
guard descriptor.numberOfItems >= 9 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)/1000
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let state = PlaybackState(
bundleIdentifier: "com.spotify.client",
isPlaying: isPlaying,
title: currentTrack,
artist: currentTrackArtist,
album: currentTrackAlbum,
currentTime: currentTime,
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
isRepeating: isRepeating,
lastUpdated: Date()
)
DispatchQueue.main.async { [weak self] in
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)")
}
}
} else {
self.playbackState = state
}
}
private func executeCommand(_ command: String) {
let script = "tell application \"Spotify\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
let script = """
tell application "Spotify"
set isRunning to true
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to shuffling
set repeatState to repeating
set artworkURL to artwork url of current track
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL}
on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""}
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
}
}

View file

@ -0,0 +1,386 @@
//
// YouTubeMusicController.swift
// boringNotch
//
// Created By Alexander Greco on 2025-03-30.
//
import Foundation
import Combine
import SwiftUI
class YouTubeMusicController: MediaControllerProtocol {
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
bundleIdentifier: "com.github.th-ch.youtube-music",
isPlaying: true
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
private let baseURL = "http://localhost:26538"
private var accessToken: String?
private var refreshTimer: Timer?
private var cancellables = Set<AnyCancellable>()
private var playbackInfoCancellable: AnyCancellable?
private var isAuthenticating = false
private let authQueue = DispatchQueue(label: "com.boringnotch.youtubemusicauth", qos: .background)
init() {
setupAppStateObservers()
authQueue.async { [weak self] in
self?.authenticateAndSetup()
}
}
deinit {
stopPeriodicUpdates()
cancellables.forEach { $0.cancel() }
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
// MARK: - Authentication
private func authenticateAndSetup() {
// 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()
}
}
}
}
}
private func getAccessToken(completion: @escaping (Bool) -> Void) {
guard let url = URL(string: "\(baseURL)/auth/boringNotch") else {
completion(false)
return
}
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(let error):
completion(false)
case .finished:
break
}
}, receiveValue: { [weak self] response in
self?.accessToken = response.accessToken
completion(true)
})
.store(in: &cancellables)
}
// MARK: - Protocol Implementation
func play() {
sendCommand(endpoint: "/play", method: "POST")
updatePlaybackInfo()
}
func pause() {
sendCommand(endpoint: "/pause", method: "POST")
updatePlaybackInfo()
}
func togglePlay() {
if !isActive() {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) {
NSWorkspace.shared.open(url)
}
}
sendCommand(endpoint: "/toggle-play", method: "POST")
updatePlaybackInfo()
}
func nextTrack() {
sendCommand(endpoint: "/next", method: "POST")
updatePlaybackInfo()
}
func previousTrack() {
sendCommand(endpoint: "/previous", method: "POST")
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
let seekData = ["seconds": time]
do {
request.httpBody = try JSONEncoder().encode(seekData)
} catch {
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
}
.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()
}
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
// 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
)
}
@objc private func handleAppStateChange(notification: Notification) {
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == playbackState.bundleIdentifier else {
return
}
if notification.name == NSWorkspace.didLaunchApplicationNotification {
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
)
}
}
}
private func startPeriodicUpdates() {
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()
}
refreshTimer?.tolerance = 0.5
// Initial update when timer starts
updatePlaybackInfo()
}
private func stopPeriodicUpdates() {
refreshTimer?.invalidate()
refreshTimer = nil
}
func updatePlaybackInfo() {
if !isActive() {
playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false)
stopPeriodicUpdates()
return
}
guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return }
let backgroundQueue = DispatchQueue.global(qos: .utility)
backgroundQueue.async { [weak self] in
guard let self = self else { return }
playbackInfoCancellable?.cancel()
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)
})
}
}
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()
// 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 }
}
}
}
private func sendCommand(endpoint: String, method: String = "GET") {
guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return }
var request = URLRequest(url: url)
request.httpMethod = method
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
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
}
.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)
}
}
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
}
private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? {
guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else {
if accessToken == nil {
// Token is missing, try to authenticate again
authenticateAndSetup()
}
return nil
}
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
}
}
// MARK: - API Response Models
struct AuthResponse: Codable {
let accessToken: String
}
struct PlaybackResponse: Codable {
let isPaused: Bool
let title: String
let artist: String
let album: String?
let elapsedSeconds: Double
let songDuration: Double
let imageSrc: String?
}