#if !APPSTORE import Foundation import AppKit import Combine // MARK: - Music Controller /// Observes Apple Music state via distributed notifications and provides playback controls. /// Singleton, @MainActor, GitHub build only. @MainActor final class MusicController: ObservableObject { static let shared = MusicController() @Published var trackTitle: String? @Published var artist: String? @Published var album: String? private var notifTokens: [Any] = [] private var cancellables = Set() private let queue = DispatchQueue(label: "fr.louisraille.coucou.music") private var isPillActive: Bool { AppState.shared.activeIntegrations.contains("integration_music") } private init() { // playerInfo fires whenever Music state changes (play/pause/track change). // Extract Sendable String? values before crossing into @MainActor. let tok1 = DistributedNotificationCenter.default().addObserver( forName: NSNotification.Name("com.apple.Music.playerInfo"), object: nil, queue: .main ) { [weak self] notif in let info = notif.userInfo let playerState = info?["Player State"] as? String let name = info?["Name"] as? String let artist = info?["Artist"] as? String let album = info?["Album"] as? String Task { @MainActor [weak self] in self?.handlePlayerInfo(playerState: playerState, name: name, artist: artist, album: album) } } notifTokens.append(tok1) // Track Music launch — read current state only if granted and pill active let tok2 = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main ) { [weak self] notif in let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier guard bundleId == "com.apple.Music" else { return } Task { @MainActor [weak self] in guard let self else { return } guard self.isPillActive, UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") else { return } self.fetchAndApply() } } notifTokens.append(tok2) // Clear state when Music quits let tok3 = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main ) { [weak self] notif in let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier guard bundleId == "com.apple.Music" else { return } Task { @MainActor [weak self] in self?.clearState() } } notifTokens.append(tok3) // Observe activeIntegrations — pill activated → initial read; deactivated → clear AppState.shared.$activeIntegrations .sink { [weak self] integrations in guard let self else { return } if integrations.contains("integration_music") { if self.isMusicRunning(), UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") { self.fetchAndApply() } } else { self.clearState() } } .store(in: &cancellables) } // MARK: - Private helpers private func isMusicRunning() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.Music" } } // MARK: - Metadata cleaners private static func shortTitle(_ raw: String) -> String { guard !raw.isEmpty else { return raw } var s = raw // Cut at first " - " if let r = s.range(of: " - ") { s = String(s[.. String { guard !raw.isEmpty else { return raw } let lower = raw.lowercased() for tag in [" feat.", " ft."] { if let r = lower.range(of: tag) { let result = String(raw[..= 4 else { return } let playing = values[0] == "playing" let wasPlaying = AppState.shared.musicPlaying trackTitle = values[1].isEmpty ? nil : Self.shortTitle(values[1]) artist = values[2].isEmpty ? nil : Self.shortArtist(values[2]) album = values[3].isEmpty ? nil : values[3] AppState.shared.musicPlaying = playing syncTaskName() if playing && !wasPlaying { NotificationCenter.default.post(name: .musicReveal, object: nil) } } } private func clearState() { trackTitle = nil; artist = nil; album = nil AppState.shared.musicPlaying = false syncTaskName() } private func syncTaskName() { guard let idx = AppState.shared.tasks.firstIndex(where: { $0.id == "integration_music" }) else { return } let title = trackTitle ?? "" AppState.shared.tasks[idx].name = title.isEmpty ? (PillCatalog.definition(for: "integration_music")?.name ?? "Apple Music") : title } // MARK: - Playback controls func playPause() { guard isMusicRunning() else { return } Task { await runAppleScript(#"tell application id "com.apple.Music" to playpause"#) } } func nextTrack() { guard isMusicRunning() else { return } Task { await runAppleScript(#"tell application id "com.apple.Music" to next track"#) } } func previousTrack() { guard isMusicRunning() else { return } Task { await runAppleScript(#"tell application id "com.apple.Music" to back track"#) } } func openMusic() { if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.Music" }) { app.activate(options: .activateIgnoringOtherApps) } else { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Music.app")) } } func openAutomationSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { NSWorkspace.shared.open(url) } } // MARK: - AppleScript runner enum ScriptResult { case success([String]), denied, error } @discardableResult private func runAppleScript(_ source: String) async -> ScriptResult { await withCheckedContinuation { cont in queue.async { let script = NSAppleScript(source: source)! var errDict: NSDictionary? let desc = script.executeAndReturnError(&errDict) if let errDict { let code = (errDict[NSAppleScript.errorNumber] as? Int) ?? 0 if code == -1743 { Task { @MainActor in AppState.shared.musicAutomationDenied = true UserDefaults.standard.set(false, forKey: "coucou.musicAutomationGranted") } cont.resume(returning: .denied) } else { cont.resume(returning: .error) } return } Task { @MainActor in UserDefaults.standard.set(true, forKey: "coucou.musicAutomationGranted") AppState.shared.musicAutomationDenied = false } // Extract values on this queue before resuming (avoids NSAppleEventDescriptor Sendable issues) var values: [String] = [] let count = desc.numberOfItems if count > 0 { for i in 1...count { values.append(desc.atIndex(i)?.stringValue ?? "") } } else { values = [desc.stringValue ?? ""] } cont.resume(returning: .success(values)) } } } } #endif