904b9b3d-c0eb-42f3-acef-958.../Sources/sources/SpotifyAppleScript.swift

154 lines
5.2 KiB
Swift
Raw Normal View History

v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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"
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
}
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
// 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.
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
with timeout of 2 seconds
tell application "Spotify"
try
return artwork url of current track
on error
return ""
end try
end tell
end timeout
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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"
)
}
}