// // SpotifyAppleScript.swift // MioIsland Music Plugin // // AppleScript bridge for the Spotify desktop app. Spotify exposes a rich // scripting dictionary so we can pull title / artist / album / duration / // position and drive transport controls. Artwork URL is also scriptable // which makes this cheaper than any other source. // // Threading: all scripts run on a background queue via runAppleScript. // NSAppleScript is NOT safe to share across threads; each call creates a // fresh instance. Duration from Spotify is in milliseconds (we divide by // 1000 inside the script) and player position is in seconds. // import AppKit struct AppleScriptTrackInfo { var title: String var artist: String var album: String var duration: TimeInterval var elapsedTime: TimeInterval var isPlaying: Bool var artworkURL: String? var source: String // "Spotify" / "Apple Music" var bundleId: String } enum SpotifyAppleScript { static let bundleId = "com.spotify.client" private static let sourceName = "Spotify" /// Fast check: is Spotify actually running? When false, skip AppleScript /// entirely — the 2s `with timeout` would still trip but that's two /// wasted seconds per router pass for an app the user isn't using. static var isRunning: Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleId } } // MARK: - Fetch static func fetch() async -> AppleScriptTrackInfo? { // `with timeout of 2 seconds` bounds the tell block; if Spotify hangs, // AppleScript raises errAETimeout (-1712) and our Swift layer returns // nil so the source router can move on instead of the serial queue // stalling for the default 120-second AppleEvent timeout. let script = """ tell application "System Events" if not (exists process "Spotify") then return "NOT_RUNNING" end tell with timeout of 2 seconds tell application "Spotify" if player state is playing or player state is paused then set trackName to name of current track set trackArtist to artist of current track set trackAlbum to album of current track set trackDuration to duration of current track set trackPosition to player position set stateString to "PAUSED" if player state is playing then set stateString to "PLAYING" set artURL to "" try set artURL to artwork url of current track end try return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL else return "NOT_PLAYING" end if end tell end timeout """ guard let raw = await runAppleScript(script, tag: "spotify") else { return nil } if raw == "NOT_RUNNING" || raw == "NOT_PLAYING" { return nil } let parts = raw.components(separatedBy: "||") guard parts.count >= 6 else { return nil } let isPlaying = parts[0] == "PLAYING" let artURL = parts.count >= 7 ? parts[6] : "" return AppleScriptTrackInfo( title: parts[1], artist: parts[2], album: parts[3], duration: TimeInterval(parts[4]) ?? 0, elapsedTime: TimeInterval(parts[5]) ?? 0, isPlaying: isPlaying, artworkURL: artURL.isEmpty ? nil : artURL, source: sourceName, bundleId: bundleId ) } // MARK: - Artwork static func fetchArtwork() async -> NSImage? { let script = """ tell application "System Events" if not (exists process "Spotify") then return "" end tell with timeout of 2 seconds tell application "Spotify" try return artwork url of current track on error return "" end try end tell end timeout """ guard let urlString = await runAppleScript(script, tag: "spotify-art"), !urlString.isEmpty, let url = URL(string: urlString) else { return nil } return await downloadImage(from: url) } // MARK: - Controls static func togglePlay() { runAppleScriptFireAndForget( "tell application \"Spotify\" to playpause", tag: "spotify-toggle" ) } static func next() { runAppleScriptFireAndForget( "tell application \"Spotify\" to next track", tag: "spotify-next" ) } static func previous() { runAppleScriptFireAndForget( "tell application \"Spotify\" to previous track", tag: "spotify-prev" ) } static func seek(to time: TimeInterval) { let clamped = max(0, time) runAppleScriptFireAndForget( "tell application \"Spotify\" to set player position to \(clamped)", tag: "spotify-seek" ) } }