53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
11 KiB
Swift
280 lines
11 KiB
Swift
#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<AnyCancellable>()
|
|
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[..<r.lowerBound])
|
|
}
|
|
// Strip trailing (...) or [...] groups repeatedly
|
|
var changed = true
|
|
while changed {
|
|
changed = false
|
|
let t = s.trimmingCharacters(in: .whitespaces)
|
|
guard let last = t.last, (last == ")" || last == "]") else { break }
|
|
let open: Character = last == ")" ? "(" : "["
|
|
if let idx = t.lastIndex(of: open) {
|
|
let candidate = String(t[..<idx]).trimmingCharacters(in: .whitespaces)
|
|
if !candidate.isEmpty { s = candidate; changed = true }
|
|
} else { break }
|
|
}
|
|
let result = s.trimmingCharacters(in: .whitespaces)
|
|
return result.isEmpty ? raw : result
|
|
}
|
|
|
|
private static func shortArtist(_ raw: String) -> 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[..<r.lowerBound]).trimmingCharacters(in: .whitespaces)
|
|
return result.isEmpty ? raw : result
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
private func handlePlayerInfo(playerState: String?, name: String?, artist inputArtist: String?, album inputAlbum: String?) {
|
|
guard isPillActive else { return }
|
|
|
|
let playing = playerState == "Playing"
|
|
let wasPlaying = AppState.shared.musicPlaying
|
|
|
|
trackTitle = name.map { Self.shortTitle($0) }.flatMap { $0.isEmpty ? nil : $0 }
|
|
artist = inputArtist.map { Self.shortArtist($0) }.flatMap { $0.isEmpty ? nil : $0 }
|
|
album = inputAlbum
|
|
|
|
AppState.shared.musicPlaying = playing
|
|
syncTaskName()
|
|
|
|
// Reveal only on transition from not-playing → playing
|
|
if playing && !wasPlaying {
|
|
NotificationCenter.default.post(name: .musicReveal, object: nil)
|
|
}
|
|
}
|
|
|
|
private func fetchAndApply() {
|
|
Task {
|
|
let result = await runAppleScript("""
|
|
tell application id "com.apple.Music"
|
|
set ps to player state as string
|
|
if ps is "stopped" then return {ps, "", "", ""}
|
|
try
|
|
set tr to current track
|
|
set n to name of tr
|
|
on error
|
|
return {ps, "", "", ""}
|
|
end try
|
|
set ar to ""
|
|
set al to ""
|
|
try
|
|
set ar to artist of tr
|
|
end try
|
|
try
|
|
set al to album of tr
|
|
end try
|
|
return {ps, n, ar, al}
|
|
end tell
|
|
""")
|
|
guard case .success(let values) = result, values.count >= 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
|