904b9b3d-c0eb-42f3-acef-958.../Sources/sources/AppleMusicAppleScript.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

99 lines
3.5 KiB
Swift

//
// AppleMusicAppleScript.swift
// MioIsland Music Plugin
//
// AppleScript bridge for the Music.app (macOS 10.15+). Compared to Spotify
// the duration field is already in seconds, and artwork is exposed as raw
// data rather than a URL so we have to ask for the "data size" and then
// fish the bytes out separately. For simplicity we skip artwork here and
// let MediaRemote (when available) or a future enhancement provide covers.
//
import AppKit
enum AppleMusicAppleScript {
private static let bundleId = "com.apple.Music"
private static let sourceName = "Apple Music"
// MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? {
// Music.app occasionally stalls its AppleEvent handler (observed in
// macOS 15.x when the app is mid-sync). Without an explicit timeout
// each fetch inherits the 120-second default, which freezes the whole
// source router for 2 minutes. `with timeout of 2 seconds` raises
// errAETimeout (-1712) if Music doesn't respond quickly, and our
// Swift layer turns that into nil so the router can move on.
let script = """
tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING"
end tell
with timeout of 2 seconds
tell application "Music"
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"
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition
else
return "NOT_PLAYING"
end if
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "music") else { return nil }
if raw == "NOT_RUNNING" || raw == "NOT_PLAYING" { return nil }
let parts = raw.components(separatedBy: "||")
guard parts.count >= 6 else { return nil }
return AppleScriptTrackInfo(
title: parts[1],
artist: parts[2],
album: parts[3],
duration: TimeInterval(parts[4]) ?? 0,
elapsedTime: TimeInterval(parts[5]) ?? 0,
isPlaying: parts[0] == "PLAYING",
artworkURL: nil,
source: sourceName,
bundleId: bundleId
)
}
// MARK: - Controls
static func togglePlay() {
runAppleScriptFireAndForget(
"tell application \"Music\" to playpause",
tag: "music-toggle"
)
}
static func next() {
runAppleScriptFireAndForget(
"tell application \"Music\" to next track",
tag: "music-next"
)
}
static func previous() {
runAppleScriptFireAndForget(
"tell application \"Music\" to previous track",
tag: "music-prev"
)
}
static func seek(to time: TimeInterval) {
let clamped = max(0, time)
runAppleScriptFireAndForget(
"tell application \"Music\" to set player position to \(clamped)",
tag: "music-seek"
)
}
}