mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
Host-facing: - Info.plist requests a 440x340 expanded panel via the new MioPluginPreferredWidth/MioPluginPreferredHeight keys (MioIsland v2.1.8+). Old hosts ignore the keys and use their default. UI: - Fix vertical stretching of the playing card. Outer ZStack now centers children instead of wrapping in a maxHeight:.infinity frame which was letting an inner Spacer propagate fill-height up to the top-level VStack. - Hero HStack clipped to album art height (128pt) so the meta column can't bleed a fill-height hint upward either. Data: - Apple Music artwork is now fetched via a temp file (write artwork data of current track to /tmp, load NSImage from disk). First-class cover art instead of the generic music.note placeholder. - apply(appleScript:) clears albumArt when the track identity changes so the next refresh reloads cover art for the new track. Latency: - Poll interval 3s → 1.5s. Track changes typically reflect within 2s. - Also subscribe to the legacy com.apple.iTunes.playerInfo distributed notification in addition to com.apple.Music.playerInfo — some builds of Music.app still emit the iTunes name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
5.0 KiB
Swift
138 lines
5.0 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 {
|
|
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: - Artwork
|
|
|
|
/// Apple Music stores artwork as embedded raw data (PNG/JPEG) rather than
|
|
/// a URL. The cheapest way to pull it via AppleScript is to write the
|
|
/// bytes to a temp file and load NSImage from it. The script writes to
|
|
/// /tmp/mio-apple-music-art.dat (fixed path — overwrites each call).
|
|
static func fetchArtwork() async -> NSImage? {
|
|
let tmpPath = "/tmp/mio-plugin-music-current-art.dat"
|
|
let script = """
|
|
tell application "System Events"
|
|
if not (exists process "Music") then return "NOT_RUNNING"
|
|
end tell
|
|
with timeout of 3 seconds
|
|
tell application "Music"
|
|
if player state is stopped then return "STOPPED"
|
|
try
|
|
set artData to data of artwork 1 of current track
|
|
set f to open for access POSIX file "\(tmpPath)" with write permission
|
|
set eof f to 0
|
|
write artData to f
|
|
close access f
|
|
return "OK"
|
|
on error errMsg
|
|
try
|
|
close access POSIX file "\(tmpPath)"
|
|
end try
|
|
return "NO_ARTWORK"
|
|
end try
|
|
end tell
|
|
end timeout
|
|
"""
|
|
guard let raw = await runAppleScript(script, tag: "music-art"),
|
|
raw == "OK" else {
|
|
return nil
|
|
}
|
|
let url = URL(fileURLWithPath: tmpPath)
|
|
return await Task.detached { NSImage(contentsOf: url) }.value
|
|
}
|
|
|
|
// 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"
|
|
)
|
|
}
|
|
}
|