mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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:
parent
c67ddd0024
commit
63885fe121
15
Info.plist
15
Info.plist
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user