904b9b3d-c0eb-42f3-acef-958.../Sources/sources/SpotifyAppleScript.swift
徐翔宇 c67ddd0024 v2.0.1: compact UI + AppleScript timeouts
UI polish (ExpandedView rewrite):
- Horizontal hero row: 128×128 album art on the left, title/artist/
  album + source badge on the right. Half the vertical footprint of
  v2.0.0 at the same info density.
- Dropped the "NOW PLAYING" eyebrow (redundant with the source badge).
- Tightened outer padding 28 → 20, inter-section spacing 22-28 → 16.
- Play button 56 → 48, prev/next 44 → 36; still 44pt tap targets via
  the invisible hover frame.

AppleScript timeout fix (the real bug, unrelated to UI):
- Every fetch() script now wraps the `tell application` block in
  `with timeout of N seconds` (2s for Spotify/Music, 3s for Chrome).
- Music.app hanging was stalling the entire source router for 120s
  (default AppleEvent timeout), freezing the UI on stale Spotify data.
- runAppleScript() suppresses error -1712 (errAETimeout) alongside
  existing -600 / -1728 — expected, not noisy.

Info.plist: CFBundleShortVersionString 2.0.0 → 2.0.1,
CFBundleVersion 2 → 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:09:29 +08:00

145 lines
4.9 KiB
Swift

//
// 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 {
private static let bundleId = "com.spotify.client"
private static let sourceName = "Spotify"
// 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"
)
}
}