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) {