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