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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.1</string>
|
<string>2.0.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>3</string>
|
<string>5</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>MusicPlugin.MusicPlugin</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin {
|
|||||||
var id: String { "music-player" }
|
var id: String { "music-player" }
|
||||||
var name: String { "Music Player" }
|
var name: String { "Music Player" }
|
||||||
var icon: String { "music.note" }
|
var icon: String { "music.note" }
|
||||||
var version: String { "2.0.1" }
|
var version: String { "2.0.3" }
|
||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
NSLog("[mio-plugin-music] activate")
|
NSLog("[mio-plugin-music] activate")
|
||||||
|
|||||||
@ -124,13 +124,23 @@ final class NowPlayingState: ObservableObject {
|
|||||||
object: nil
|
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(
|
DistributedNotificationCenter.default().addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(musicStateChanged),
|
selector: #selector(musicStateChanged),
|
||||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
DistributedNotificationCenter.default().addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(musicStateChanged),
|
||||||
|
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
startPolling()
|
startPolling()
|
||||||
refresh()
|
refresh()
|
||||||
@ -160,7 +170,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
pollTimer?.invalidate()
|
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() }
|
Task { @MainActor in self?.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,6 +256,12 @@ final class NowPlayingState: ObservableObject {
|
|||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
||||||
await MainActor.run { self.apply(appleScript: info) }
|
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
|
return .appleMusic
|
||||||
|
|
||||||
case .chrome:
|
case .chrome:
|
||||||
@ -272,6 +292,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func apply(appleScript info: AppleScriptTrackInfo) {
|
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.title = info.title
|
||||||
self.artist = info.artist
|
self.artist = info.artist
|
||||||
self.album = info.album
|
self.album = info.album
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
enum AppleMusicAppleScript {
|
enum AppleMusicAppleScript {
|
||||||
private static let bundleId = "com.apple.Music"
|
static let bundleId = "com.apple.Music"
|
||||||
private static let sourceName = "Apple Music"
|
private static let sourceName = "Apple Music"
|
||||||
|
|
||||||
// MARK: - Fetch
|
// 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
|
// MARK: - Controls
|
||||||
|
|
||||||
static func togglePlay() {
|
static func togglePlay() {
|
||||||
|
|||||||
@ -44,14 +44,16 @@ struct ExpandedView: View {
|
|||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack(alignment: .center) {
|
||||||
AlbumArtColorExtractor
|
AlbumArtColorExtractor
|
||||||
.backgroundGradient(for: tintColor)
|
.backgroundGradient(for: tintColor)
|
||||||
.ignoresSafeArea()
|
.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
|
content
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Self.base)
|
.background(Self.base)
|
||||||
@ -108,12 +110,16 @@ struct ExpandedView: View {
|
|||||||
|
|
||||||
private var playingCard: some View {
|
private var playingCard: some View {
|
||||||
VStack(spacing: 16) {
|
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) {
|
HStack(alignment: .top, spacing: 14) {
|
||||||
albumArt
|
albumArt
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
// Source badge, flush right with the artwork top
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
sourceBadge
|
sourceBadge
|
||||||
@ -136,11 +142,10 @@ struct ExpandedView: View {
|
|||||||
.foregroundColor(Self.ink.opacity(0.45))
|
.foregroundColor(Self.ink.opacity(0.45))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
.frame(height: 128)
|
||||||
|
|
||||||
// Progress + times inline on one row
|
// Progress + times inline on one row
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user