mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Restored files from old Mac, force pushing
This commit is contained in:
parent
5dd95228b8
commit
5040381840
126 changed files with 10431 additions and 67 deletions
154
DynamicIsland/MediaControllers/AppleMusicController.swift
Normal file
154
DynamicIsland/MediaControllers/AppleMusicController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
21
DynamicIsland/MediaControllers/MediaControllerProtocol.swift
Normal file
21
DynamicIsland/MediaControllers/MediaControllerProtocol.swift
Normal 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()
|
||||
}
|
||||
197
DynamicIsland/MediaControllers/NowPlayingController.swift
Normal file
197
DynamicIsland/MediaControllers/NowPlayingController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
DynamicIsland/MediaControllers/SpotifyController.swift
Normal file
168
DynamicIsland/MediaControllers/SpotifyController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
386
DynamicIsland/MediaControllers/YouTubeMusicController.swift
Normal file
386
DynamicIsland/MediaControllers/YouTubeMusicController.swift
Normal 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?
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue