From 63885fe121a17a78bcd3facd19959454841321ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Sun, 19 Apr 2026 20:42:14 +0800 Subject: [PATCH] v2.0.3: compact panel via host size hint + Apple Music artwork + faster poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Info.plist | 15 +++++++- Sources/MusicPlugin.swift | 2 +- Sources/NowPlayingState.swift | 29 ++++++++++++++- Sources/sources/AppleMusicAppleScript.swift | 41 ++++++++++++++++++++- Sources/ui/ExpandedView.swift | 19 ++++++---- 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/Info.plist b/Info.plist index e5b3478..ae24d67 100644 --- a/Info.plist +++ b/Info.plist @@ -15,10 +15,21 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2.0.1 + 2.0.3 CFBundleVersion - 3 + 5 NSPrincipalClass MusicPlugin.MusicPlugin + + MioPluginPreferredWidth + 440 + MioPluginPreferredHeight + 340 diff --git a/Sources/MusicPlugin.swift b/Sources/MusicPlugin.swift index 5c40b58..72382ed 100644 --- a/Sources/MusicPlugin.swift +++ b/Sources/MusicPlugin.swift @@ -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") diff --git a/Sources/NowPlayingState.swift b/Sources/NowPlayingState.swift index c6f15a6..a6a4081 100644 --- a/Sources/NowPlayingState.swift +++ b/Sources/NowPlayingState.swift @@ -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 diff --git a/Sources/sources/AppleMusicAppleScript.swift b/Sources/sources/AppleMusicAppleScript.swift index a968a5d..8b8ba67 100644 --- a/Sources/sources/AppleMusicAppleScript.swift +++ b/Sources/sources/AppleMusicAppleScript.swift @@ -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() { diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift index f415ab3..a22186b 100644 --- a/Sources/ui/ExpandedView.swift +++ b/Sources/ui/ExpandedView.swift @@ -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) {