Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| Sources | ||
| .gitignore | ||
| build.sh | ||
| Info.plist | ||
| README.md | ||
| README.zh-CN.md | ||
Music Player Plugin for MioIsland
v2.0.0 — full rewrite. Real Now Playing info from Spotify, Apple Music, Google Chrome (YouTube / SoundCloud / 网页版音乐), with playback controls, draggable seek bar, album art color tint, and a pseudo-spectrum in the header icon. Replaces the v1.0.0 shell that only wired up MediaRemote.
Features
- Multi-source playback tracking with sticky source priority:
- MediaRemote (private framework, any app that registers with
MPNowPlayingInfoCenter) - Spotify desktop (AppleScript)
- Apple Music desktop (AppleScript)
- Google Chrome tabs (JavaScript injection into
<video>/<audio>elements)
- MediaRemote (private framework, any app that registers with
- Full playback controls — previous, play/pause, next, draggable seek bar
- Album art color tint — extracts dominant color from artwork, uses it as a soft gradient background in the expanded view
- Pseudo-spectrum in the Notch — three animated vertical bars in the header icon that pulse while music is playing
- Bi-lingual — follows MioIsland's
appLanguagepreference (zh / en) - Graceful degradation:
- Host too old (< v2.1.7) → shows upgrade banner instead of silently failing
- Chinese music app running (QQ 音乐 / 网易云 / 酷狗) → shows "desktop not supported, try the web version" hint
Supported music apps
| App | Supported? | How |
|---|---|---|
| Spotify desktop | ✅ Full | AppleScript + MediaRemote |
| Apple Music desktop | ✅ Full | AppleScript + MediaRemote |
| YouTube / YouTube Music in Chrome | ✅ Full | JS injection |
| SoundCloud in Chrome | ✅ Full | JS injection |
| Spotify Web Player in Chrome | ✅ Full | JS injection |
| 网易云音乐 / QQ 音乐 / 酷狗 (网页版) in Chrome | ✅ Full | JS injection |
| 网易云音乐 / QQ 音乐 新版桌面 app | 🟡 Partial | MediaRemote if the app registers (macOS < 15.4 ok; 15.4+ may silently drop) |
| 酷狗桌面 app / 酷我 / 咪咕 / 其他国产 | ❌ Not supported | No MediaRemote, no AppleScript API |
Any app using MPNowPlayingInfoCenter |
✅ | MediaRemote |
macOS 15.4+ note: Apple restricted
MRMediaRemoteGetNowPlayingInfoto apps with special entitlements. The plugin auto-falls back to AppleScript for Spotify / Apple Music, and to JS injection for Chrome-based sources.
Installation
Prerequisite: MioIsland host v2.1.7 or newer
This plugin uses AppleScript to talk to Spotify / Apple Music / Chrome.
macOS requires the host app's Info.plist to declare NSAppleEventsUsageDescription
for that. MioIsland added this key in v2.1.7 — older hosts will show an
upgrade banner inside the plugin and skip AppleScript sources.
Upgrade the host:
brew upgrade codeisland
Or via MioIsland's in-app update (Sparkle will prompt automatically).
From MioIsland Plugin Store (recommended)
- Open MioIsland Settings → Plugins
- Click 打开插件市场 (Open Plugin Store) — opens https://miomio.chat
- Find Music Player v2.0.0 and click Install
- Copy the generated
https://api.miomio.chat/api/i/...URL - Paste into the Install from URL field and click Install
- Restart MioIsland (menu bar → quit → relaunch)
Manual installation
# Download the latest release from GitHub
curl -LO https://github.com/MioMioOS/mio-plugin-music/releases/latest/download/music-player.zip
unzip music-player.zip
mkdir -p ~/.config/codeisland/plugins/
cp -R music-player.bundle ~/.config/codeisland/plugins/
# Restart MioIsland
First-run permissions
When the plugin fetches track info for the first time, macOS will prompt:
"Mio Island" would like to control "Spotify".app.
Click OK. Do the same for Music.app and Google Chrome when they come up. These permissions are granted to the host app once and remembered forever — subsequent launches don't re-prompt.
If you accidentally clicked Don't Allow, fix it in: System Settings → Privacy & Security → Automation → toggle Mio Island on for each target app.
Chrome-specific setup
To get Chrome playback (YouTube, SoundCloud, etc.) working:
- Open Chrome
- Menu bar: View → Developer → Allow JavaScript from Apple Events
- Check the option (click if unchecked)
This is a one-time setting that lives in Chrome's preferences. Without it, AppleScript JS injection will silently return nothing for Chrome tabs.
Building from source
Requirements:
- macOS 15.0+
- Xcode 16+ Command Line Tools
- Swift 5.10+
git clone https://github.com/MioMioOS/mio-plugin-music.git
cd mio-plugin-music
./build.sh # → build/music-player.bundle + build/music-player.zip
./build.sh install # (not implemented — copy manually)
Install the build output:
cp -R build/music-player.bundle ~/.config/codeisland/plugins/
Restart MioIsland.
Plugin architecture (v2.0.0)
Sources/
├── MioPlugin.swift — plugin SDK protocol (DO NOT MODIFY)
├── MusicPlugin.swift — principal class (activate / makeView / header slot)
├── NowPlayingState.swift — @MainActor ObservableObject, source router
├── sources/
│ ├── MediaRemoteSource.swift — dlopen private MediaRemote.framework
│ ├── SpotifyAppleScript.swift
│ ├── AppleMusicAppleScript.swift
│ └── ChromeWebSource.swift — JS injection into <video> / <audio>
├── ui/
│ ├── ExpandedView.swift — main 620×780 panel
│ ├── HeaderSlotView.swift — 20×20 header icon + pseudo-spectrum
│ ├── AlbumArtColorExtractor.swift
│ └── SeekBar.swift
└── support/
├── ChineseAppDetector.swift — QQ / NetEase / Kugou detection
├── HostVersionCheck.swift — host ≥ v2.1.7 gate
└── Localization.swift — zh / en strings
Source priority routing: sticky (last successful) → MediaRemote → Spotify → Apple Music → Chrome. First source that returns non-empty playback state wins. 3-second poll timer drives periodic refresh; Spotify and Music distributed notifications trigger immediate refresh for instant reaction.
Privacy
The plugin reads only:
- System-level Now Playing metadata (MediaRemote)
- Current track info from Spotify / Music / Chrome via AppleScript
- Bundle IDs of running apps to detect which source is active
It does not read:
- Your listening history
- Anything outside the "currently playing" state
- Anything from apps that are not music-related
Nothing is sent to any server. All processing happens locally.
License
MIT. See LICENSE.