v2.0.3: compact panel via host size hint + Apple Music artwork + faster poll

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>
This commit is contained in:
徐翔宇 2026-04-19 20:42:14 +08:00
parent c67ddd0024
commit 63885fe121
5 changed files with 93 additions and 13 deletions

View File

@ -15,10 +15,21 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.1</string>
<string>2.0.3</string>
<key>CFBundleVersion</key>
<string>3</string>
<string>5</string>
<key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string>
<!--
Optional size hint for the expanded plugin panel.
Host reads these on plugin load and caps the expanded area to the
requested dimensions instead of the default ~620x780. Both keys
must be present. Range: width 280-1200, height 180-900. Values
outside that range are ignored and the host falls back to default.
-->
<key>MioPluginPreferredWidth</key>
<integer>440</integer>
<key>MioPluginPreferredHeight</key>
<integer>340</integer>
</dict>
</plist>

View File

@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin {
var id: String { "music-player" }
var name: String { "Music Player" }
var icon: String { "music.note" }
var version: String { "2.0.1" }
var version: String { "2.0.3" }
func activate() {
NSLog("[mio-plugin-music] activate")

View File

@ -124,13 +124,23 @@ final class NowPlayingState: ObservableObject {
object: nil
)
// Observe Apple Music similarly.
// Observe Apple Music. macOS 15+ Music.app emits
// com.apple.Music.playerInfo; older iTunes emitted
// com.apple.iTunes.playerInfo. Register both so track changes are
// picked up instantly regardless of which one the current build
// broadcasts.
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
object: nil
)
startPolling()
refresh()
@ -160,7 +170,11 @@ final class NowPlayingState: ObservableObject {
private func startPolling() {
pollTimer?.invalidate()
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
// 1.5s poll balances track-change detection latency (previously 3s)
// against the cost of repeated AppleScript probes. At 1.5s, switching
// between tracks in Apple Music typically reflects in the UI within
// two seconds including AppleScript round-trip.
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refresh() }
}
}
@ -242,6 +256,12 @@ final class NowPlayingState: ObservableObject {
case .appleMusic:
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
await MainActor.run { self.apply(appleScript: info) }
// Apple Music doesn't expose an artwork URL via AppleScript;
// we dump the raw bytes to /tmp and reload. Only refetch when
// the track identity actually changes to avoid hammering disk.
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return .appleMusic
case .chrome:
@ -272,6 +292,11 @@ final class NowPlayingState: ObservableObject {
}
private func apply(appleScript info: AppleScriptTrackInfo) {
// Track changed drop cached artwork so the source can refetch
// (Spotify does URL-based, Apple Music does raw-bytes-via-temp-file).
if self.title != info.title || self.artist != info.artist {
self.albumArt = nil
}
self.title = info.title
self.artist = info.artist
self.album = info.album

View File

@ -12,7 +12,7 @@
import AppKit
enum AppleMusicAppleScript {
private static let bundleId = "com.apple.Music"
static let bundleId = "com.apple.Music"
private static let sourceName = "Apple Music"
// MARK: - Fetch
@ -65,6 +65,45 @@ enum AppleMusicAppleScript {
)
}
// 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() {

View File

@ -44,14 +44,16 @@ struct ExpandedView: View {
// MARK: - Body
var body: some View {
ZStack {
ZStack(alignment: .center) {
AlbumArtColorExtractor
.backgroundGradient(for: tintColor)
.ignoresSafeArea()
// ZStack's default alignment centers children to their intrinsic
// size. We rely on that instead of a .frame wrapper so content
// doesn't get silently stretched vertically.
content
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Self.base)
@ -108,12 +110,16 @@ struct ExpandedView: View {
private var playingCard: some View {
VStack(spacing: 16) {
// Hero row: album art left, metadata + source badge right
// Hero row: album art left, metadata + source badge right.
// We bound the HStack to the album art height (128) so the meta
// column can't propagate a fill-height hint up to the outer
// VStack. (Previous version let an inner Spacer bleed through,
// which shoved the progress bar and controls to the panel's
// bottom edge with ~500pt of dead space in the middle.)
HStack(alignment: .top, spacing: 14) {
albumArt
VStack(alignment: .leading, spacing: 4) {
// Source badge, flush right with the artwork top
HStack {
Spacer()
sourceBadge
@ -136,11 +142,10 @@ struct ExpandedView: View {
.foregroundColor(Self.ink.opacity(0.45))
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(height: 128)
// Progress + times inline on one row
VStack(spacing: 6) {