2026-04-18 18:27:21 +00:00
|
|
|
//
|
|
|
|
|
// 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 {
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
static let bundleId = "com.spotify.client"
|
2026-04-18 18:27:21 +00:00
|
|
|
private static let sourceName = "Spotify"
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
// MARK: - Fetch
|
|
|
|
|
|
|
|
|
|
static func fetch() async -> AppleScriptTrackInfo? {
|
2026-04-19 03:09:29 +00:00
|
|
|
// `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.
|
2026-04-18 18:27:21 +00:00
|
|
|
let script = """
|
|
|
|
|
tell application "System Events"
|
|
|
|
|
if not (exists process "Spotify") then return "NOT_RUNNING"
|
|
|
|
|
end tell
|
2026-04-19 03:09:29 +00:00
|
|
|
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
|
2026-04-18 18:27:21 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-19 03:09:29 +00:00
|
|
|
with timeout of 2 seconds
|
|
|
|
|
tell application "Spotify"
|
|
|
|
|
try
|
|
|
|
|
return artwork url of current track
|
|
|
|
|
on error
|
|
|
|
|
return ""
|
|
|
|
|
end try
|
|
|
|
|
end tell
|
|
|
|
|
end timeout
|
2026-04-18 18:27:21 +00:00
|
|
|
"""
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|