904b9b3d-c0eb-42f3-acef-958.../Sources/NowPlayingState.swift

885 lines
34 KiB
Swift
Raw Permalink Normal View History

v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
//
// NowPlayingState.swift
// MioIsland Music Plugin
//
// Single source of truth consumed by the SwiftUI layer. Aggregates four
// backend sources, in priority order:
//
// 1. The most recently successful source (sticky preference so we do not
// thrash between Spotify / Music / Chrome on every poll).
// 2. MediaRemote (private framework; falls back on macOS 15.4+ where it
// returns an empty dictionary without a special entitlement).
// 3. Spotify desktop via AppleScript.
// 4. Apple Music via AppleScript.
// 5. Google Chrome tab via JS injection.
//
// Also checks:
// - Host version (must be 2.1.7 for NSAppleEventsUsageDescription).
// - Chinese desktop players (QQ / / ) so we can show a
// "desktop unsupported, use web" state instead of empty UI.
//
// Timing:
// - 3 second poll timer drives periodic refresh.
// - MediaRemote notifications (when available) trigger immediate refresh.
// - A 1 second local timer advances elapsedTime while isPlaying is true.
//
import AppKit
import Combine
// MARK: - Source enum
enum NowPlayingSourceKind: String {
case none
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
/// Atoll-style MediaRemoteAdapter subprocess stream bypasses the
/// macOS 15.4+ entitlement gate and gives us real-time system Now
/// Playing with artwork, duration, and elapsed time.
case mediaRemoteAdapter
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case mediaRemote
case spotify
case appleMusic
case chrome
}
// MARK: - State
@MainActor
final class NowPlayingState: ObservableObject {
static let shared = NowPlayingState()
// Track info
@Published var title: String = ""
@Published var artist: String = ""
@Published var album: String = ""
@Published var albumArt: NSImage?
/// Populated by Worker B's AlbumArtColorExtractor once albumArt changes.
@Published var albumArtColor: NSColor?
@Published var isPlaying: Bool = false
@Published var duration: TimeInterval = 0
@Published var elapsedTime: TimeInterval = 0
/// Human readable source label ("Spotify" / "Apple Music" / "YouTube" / )
@Published var sourceName: String = ""
/// Bundle identifier of the app that owns the current playback, for
/// NSWorkspace icon lookups by the UI layer.
@Published var sourceBundleId: String = ""
/// False when Mio Island host is older than HostVersionCheck.minRequired.
/// UI should show an upgrade banner and skip AppleScript sources.
@Published var hostVersionOK: Bool = true
/// Non-nil when a Chinese desktop player is running. UI shows a "
/// 使" hint.
@Published var chineseAppDetected: String?
v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl Critical fix · adapter stream was silently empty v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The `stream` mode wraps every emit as: {"type":"data","diff":<bool>,"payload":{title,...}} but Swift was decoding as AdapterStreamPayload directly (the shape used by `get`, which is flat). Result: every `stream rx` had title="" because the real data was nested inside payload, so hasTrack was always false and onUpdate never fired. Users saw "nothing playing" even while Apple Music was running. New AdapterStreamEnvelope decodes the wrapper, extracts payload, and also honours `diff: false` to reset currentInfo before merging (stale fields from the previous track were otherwise leaking). Added bootstrap path · cold start with music already playing When the adapter subprocess is spawned AFTER Apple Music is already playing, the stream's initial emit can be an empty baseline. A parallel one-shot `perl adapter.pl get` at spawn+300ms catches the current track immediately. Added file-based debug log at /tmp/mio-plugin-music-debug.log NSLog / os_log are unreliably filtered on macOS 15, and we can't attach Xcode to a plugin loaded from a signed host. A line-oriented log at a fixed path is the one channel that's always readable post- mortem. Lines include stream rx / bootstrap / parse failures. Real lyrics · LRCLIB integration - New LyricsService fetches synced lyrics via https://lrclib.net/api/get (exact) + /search (fallback), parses LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries, 1-hour TTL). Negative-caches "not found" so obscure tracks don't re-hit the API every render. - NowPlayingState gains syncedLyrics + currentLyricIndex @Published. applyAdapterUpdate detects track changes and refreshes lyrics detached; the 1s playback timer updates currentLyricIndex. - DesktopLyricsViews replaces the placeholder text with real lyric text from syncedLyrics[currentLyricIndex ± 1]. Falls back to sensible dots when no lyrics loaded / instrumentals. Bonus · robust vinyl spin via TimelineView withAnimation(.linear.repeatForever) loses the animation when SwiftUI re-creates the view (window hide/show, style switch). Replaced with TimelineView driven by wall-clock — angle = (elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses correctly via `paused: !isPlaying`. Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll) MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:55:04 +00:00
/// Parsed synced lyrics from LRCLIB after every track change. Empty
/// array = we tried, nothing found (instrumentals / obscure tracks).
@Published var syncedLyrics: [LyricLine] = []
/// Index into `syncedLyrics` for the current playhead. -1 means no
/// lyrics loaded OR elapsedTime < first line's timestamp. Updated
/// by the playback timer every second.
@Published var currentLyricIndex: Int = -1
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
// MARK: - Derived
var progress: Double {
guard duration > 0 else { return 0 }
return max(0, min(1, elapsedTime / duration))
}
var formattedElapsed: String { Self.format(elapsedTime) }
var formattedDuration: String { Self.format(duration) }
private static func format(_ t: TimeInterval) -> String {
guard t.isFinite, t >= 0 else { return "0:00" }
let total = Int(t)
let minutes = total / 60
let seconds = total % 60
return String(format: "%d:%02d", minutes, seconds)
}
// MARK: - Private
private let mediaRemote = MediaRemoteSource()
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
/// Atoll-style subprocess adapter. Optional because the bundle may be
/// missing the Resources/mediaremote-adapter payload (dev builds, old
/// plugin versions). When non-nil, it becomes the primary source and
/// most of the legacy polling / AppleScript chain stays dormant.
private let mediaRemoteAdapter: MediaRemoteAdapterSource? = MediaRemoteAdapterSource()
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
private var pollTimer: Timer?
private var playbackTimer: Timer?
private var cancellables = Set<AnyCancellable>()
private var stickySource: NowPlayingSourceKind = .none
private var lastChromeTabURL: String = ""
private var isRunning = false
private var refreshInFlight = false
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
/// macOS 15.4+ gates MRMediaRemoteGetNowPlayingInfo behind a private
/// entitlement. When the call returns an empty dict we mark the API
/// as blocked and skip it for 60 seconds before retrying (macOS minor
/// updates can flip the entitlement state, so we don't mark "blocked
/// forever"). Saves ~50ms per refresh when blocked, but more importantly
/// lets the router hit AppleScript on the first pass instead of the
/// second ~1s faster cold start on restricted systems.
private var mediaRemoteBlockedUntil: Date?
/// NSWorkspace observers for app launch/terminate. When a music app
/// opens or closes, refresh immediately these events beat the poll
/// timer by several seconds.
private var workspaceObservers: [NSObjectProtocol] = []
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
private init() {}
// MARK: - Lifecycle
func start() {
guard !isRunning else { return }
isRunning = true
NSLog("[mio-plugin-music] NowPlayingState.start")
hostVersionOK = HostVersionCheck.isOK()
chineseAppDetected = ChineseAppDetector.detectRunning()
mediaRemote.registerForNotifications { [weak self] in
Task { @MainActor in self?.refresh() }
}
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
// Start the Atoll-style adapter subprocess if bundled. This is
// the PRIMARY low-latency source on 15.4+ it's the only one that
// actually produces live data without AppleScript polling. When
// it emits, we short-circuit the router entirely.
if let adapter = mediaRemoteAdapter {
adapter.onUpdate = { [weak self] info in
Task { @MainActor in self?.applyAdapterUpdate(info) }
}
adapter.start()
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
// Observe Spotify distributed notifications for instant reaction.
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(spotifyStateChanged),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
// 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.
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
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
)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
// Observe app launch / terminate when Spotify or Music opens, we
// want to detect it within the same RunLoop tick rather than waiting
// out the 15s safety-net poll.
let wsCenter = NSWorkspace.shared.notificationCenter
let trackedBundleIds: Set<String> = [
SpotifyAppleScript.bundleId,
AppleMusicAppleScript.bundleId,
ChromeWebSource.bundleId,
]
let launchToken = wsCenter.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
guard
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
trackedBundleIds.contains(bid)
else { return }
Task { @MainActor in self?.refresh() }
}
let terminateToken = wsCenter.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
guard
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
trackedBundleIds.contains(bid)
else { return }
Task { @MainActor in self?.refresh() }
}
workspaceObservers = [launchToken, terminateToken]
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
startPolling()
refresh()
}
func stop() {
guard isRunning else { return }
isRunning = false
NSLog("[mio-plugin-music] NowPlayingState.stop")
pollTimer?.invalidate()
pollTimer = nil
playbackTimer?.invalidate()
playbackTimer = nil
DistributedNotificationCenter.default().removeObserver(self)
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
let wsCenter = NSWorkspace.shared.notificationCenter
for token in workspaceObservers {
wsCenter.removeObserver(token)
}
workspaceObservers.removeAll()
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
mediaRemoteAdapter?.stop()
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
@objc private func spotifyStateChanged() {
Task { @MainActor in self.refresh() }
}
@objc private func musicStateChanged() {
Task { @MainActor in self.refresh() }
}
// MARK: - Polling
private func startPolling() {
rearmPoll()
}
/// Adaptive poll interval the event-driven fast paths aren't uniformly
/// reliable across players on modern macOS:
/// - Spotify: com.spotify.client.PlaybackStateChanged fires instantly
/// on every track change 10s safety-net is plenty.
/// - Apple Music: com.apple.Music.playerInfo is NOT reliably broadcast
/// on macOS 14+ (Apple stopped posting it in many builds). Combined
/// with MediaRemote's 15.4+ entitlement gate, there is literally no
/// event source left, so we have to poll. 0.8s gets track changes
/// visible inside 1s which is the best we can do without the
/// Atoll-style adapter framework.
/// - Chrome / web players: no notifications at all. 1.2s poll is a
/// reasonable tradeoff between latency and CPU.
/// - Idle / nothing playing: 10s is fine the NSWorkspace launch
/// observer will wake us instantly when a music app opens.
/// Recomputed and re-armed every time `stickySource` or `isPlaying`
/// changes, so the plugin idles cheaply until it has something to track.
private var currentPollInterval: TimeInterval = 10.0
private func adaptivePollInterval() -> TimeInterval {
switch stickySource {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
// Adapter subprocess pushes data in real time poll only as a
// last-resort safety net in case the subprocess silently wedges.
case .mediaRemoteAdapter: return 30.0
case .appleMusic where isPlaying: return 0.8
case .chrome where isPlaying: return 1.2
case .spotify where isPlaying: return 3.0 // event-driven, poll is just backup
case .mediaRemote where isPlaying: return 3.0
default: return 10.0
}
}
private func rearmPoll() {
let newInterval = adaptivePollInterval()
// Avoid invalidating the timer on every refresh when the interval
// didn't actually change Timer allocs aren't free and the router
// calls rearmPoll() after every successful fetch.
if let t = pollTimer, t.isValid, abs(newInterval - currentPollInterval) < 0.01 {
return
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
pollTimer?.invalidate()
currentPollInterval = newInterval
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
Task { @MainActor in self?.refresh() }
}
}
// MARK: - Source router
private func refresh() {
guard !refreshInFlight else { return }
refreshInFlight = true
// Refresh Chinese app detection each pass; user may launch/quit them.
chineseAppDetected = ChineseAppDetector.detectRunning()
let allowAppleScript = hostVersionOK
Task { [weak self] in
guard let self else { return }
await self.routeSources(allowAppleScript: allowAppleScript)
await MainActor.run { self.refreshInFlight = false }
}
}
private func routeSources(allowAppleScript: Bool) async {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
// Adapter short-circuit: when the subprocess is the sticky source
// and we already have a track from it, there's nothing to do here
// new data will arrive via `applyAdapterUpdate(_:)` whenever it
// actually changes. Polling on top of an event-driven source just
// wastes AppleScript round-trips.
if stickySource == .mediaRemoteAdapter,
!title.isEmpty,
mediaRemoteAdapter != nil {
return
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
// Running-app snapshot read once per pass so we don't hit the
// workspace API four times.
let spotifyRunning = SpotifyAppleScript.isRunning
let musicRunning = AppleMusicAppleScript.isRunning
let chromeRunning = ChromeWebSource.isRunning
// MediaRemote gate: on macOS 15.4+ the call returns an empty dict
// without entitlement. Cache that for 60s so we don't keep eating
// an IPC round-trip per refresh.
let now = Date()
let mrBlocked: Bool
if let until = mediaRemoteBlockedUntil, until > now {
mrBlocked = true
} else {
mrBlocked = false
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
// Sticky-source fast path if the last successful source is still
// a live candidate, try it alone first. One AppleScript round-trip
// when music is playing = lowest possible latency path.
if stickySource != .none, isCandidateLive(
stickySource,
spotifyRunning: spotifyRunning,
musicRunning: musicRunning,
chromeRunning: chromeRunning,
mrBlocked: mrBlocked,
allowAppleScript: allowAppleScript
) {
if let used = await tryFetch(stickySource) {
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
await MainActor.run {
self.stickySource = used
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
return
}
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
// Parallel fallback probing. `async let` fans out all live candidates
// concurrently cold start used to serialize: try MR (~50ms, miss on
// 15.4+) try Spotify AppleScript (~100-2000ms) try Music (~100-2000ms)
// try Chrome (~200ms+). Worst case ~6s. Now they all race and we
// use the first non-nil result by priority.
async let mrResult: MediaRemoteInfo? = mrBlocked ? nil : mediaRemoteFetch()
async let spotifyResult: AppleScriptTrackInfo? = (allowAppleScript && spotifyRunning)
? SpotifyAppleScript.fetch() : nil
async let musicResult: AppleScriptTrackInfo? = (allowAppleScript && musicRunning)
? AppleMusicAppleScript.fetch() : nil
async let chromeResult: ChromeTrackInfo? = (allowAppleScript && chromeRunning)
? ChromeWebSource.fetch() : nil
let mr = await mrResult
let sp = await spotifyResult
let mu = await musicResult
let ch = await chromeResult
// MediaRemote returning empty on 15.4+ marks it blocked for 60s.
if !mrBlocked, mr == nil, mediaRemoteLikelyBlocked() {
await MainActor.run {
self.mediaRemoteBlockedUntil = Date().addingTimeInterval(60)
}
}
// Priority order for picking the winner among the parallel results.
// MediaRemote first (it unifies everything when available). Then
// Spotify > Apple Music > Chrome Spotify desktop tends to have
// fuller metadata than web, and Apple Music's AppleScript is slower
// so it gets slight demotion when a competing hit exists.
if let info = mr, info.hasTrack {
await MainActor.run {
self.apply(mediaRemote: info)
self.stickySource = .mediaRemote
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
}
return
}
if let info = sp, !info.title.isEmpty {
await MainActor.run {
self.apply(appleScript: info)
self.stickySource = .spotify
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
}
if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return
}
if let info = mu, !info.title.isEmpty {
await MainActor.run {
self.apply(appleScript: info)
self.stickySource = .appleMusic
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
}
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return
}
if let info = ch, !info.title.isEmpty {
await MainActor.run {
self.apply(chrome: info)
self.stickySource = .chrome
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
}
if let artURL = info.artworkURL, let url = URL(string: artURL) {
if let image = await downloadImage(from: url) {
await MainActor.run { self.albumArt = image }
}
}
return
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
// Nothing returned a hit; clear state.
await MainActor.run {
self.clearTrack()
self.stickySource = .none
self.updatePlaybackTimer()
self.rearmPoll()
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
/// Whether a source could plausibly produce a hit right now given the
/// running-app snapshot + MediaRemote blocked state. Used to short-circuit
/// the sticky-source fast path don't probe Spotify if Spotify is closed.
private func isCandidateLive(
_ kind: NowPlayingSourceKind,
spotifyRunning: Bool,
musicRunning: Bool,
chromeRunning: Bool,
mrBlocked: Bool,
allowAppleScript: Bool
) -> Bool {
switch kind {
case .none: return false
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
case .mediaRemote: return !mrBlocked
case .spotify: return allowAppleScript && spotifyRunning
case .appleMusic: return allowAppleScript && musicRunning
case .chrome: return allowAppleScript && chromeRunning
}
}
/// Bridge the MediaRemote callback-style API to async/await so we can
/// fan it out alongside the AppleScript sources in `routeSources`.
private func mediaRemoteFetch() async -> MediaRemoteInfo? {
await withCheckedContinuation { cont in
Task { @MainActor in
self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
}
}
}
/// Heuristic for "MediaRemote returned empty because Apple blocked us,
/// not because no one is playing". If at least one of the known player
/// apps is running but MediaRemote came back nil, the cause is almost
/// certainly the 15.4+ entitlement gate.
private func mediaRemoteLikelyBlocked() -> Bool {
SpotifyAppleScript.isRunning ||
AppleMusicAppleScript.isRunning ||
ChromeWebSource.isRunning
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
/// Try a single source. Returns the source kind on success, nil on miss.
private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
switch kind {
case .none:
return nil
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter:
// Push-only source; pull-fetch is a no-op.
return nil
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case .mediaRemote:
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
Task { @MainActor in
self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
}
}
guard let info, info.hasTrack else { return nil }
await MainActor.run { self.apply(mediaRemote: info) }
return .mediaRemote
case .spotify:
guard let info = await SpotifyAppleScript.fetch(), !info.title.isEmpty else { return nil }
await MainActor.run { self.apply(appleScript: info) }
if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return .spotify
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 }
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
return .appleMusic
case .chrome:
guard let info = await ChromeWebSource.fetch(), !info.title.isEmpty else { return nil }
await MainActor.run { self.apply(chrome: info) }
if let artURL = info.artworkURL, let url = URL(string: artURL) {
if let image = await downloadImage(from: url) {
await MainActor.run { self.albumArt = image }
}
}
return .chrome
}
}
// MARK: - Apply
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
/// Called when the Atoll-style subprocess adapter emits a fresh payload.
/// This bypasses the full router adapter updates are the truest signal
/// we have on 15.4+, so we claim sticky-source and publish straight away.
private func applyAdapterUpdate(_ info: MediaRemoteInfo) {
v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl Critical fix · adapter stream was silently empty v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The `stream` mode wraps every emit as: {"type":"data","diff":<bool>,"payload":{title,...}} but Swift was decoding as AdapterStreamPayload directly (the shape used by `get`, which is flat). Result: every `stream rx` had title="" because the real data was nested inside payload, so hasTrack was always false and onUpdate never fired. Users saw "nothing playing" even while Apple Music was running. New AdapterStreamEnvelope decodes the wrapper, extracts payload, and also honours `diff: false` to reset currentInfo before merging (stale fields from the previous track were otherwise leaking). Added bootstrap path · cold start with music already playing When the adapter subprocess is spawned AFTER Apple Music is already playing, the stream's initial emit can be an empty baseline. A parallel one-shot `perl adapter.pl get` at spawn+300ms catches the current track immediately. Added file-based debug log at /tmp/mio-plugin-music-debug.log NSLog / os_log are unreliably filtered on macOS 15, and we can't attach Xcode to a plugin loaded from a signed host. A line-oriented log at a fixed path is the one channel that's always readable post- mortem. Lines include stream rx / bootstrap / parse failures. Real lyrics · LRCLIB integration - New LyricsService fetches synced lyrics via https://lrclib.net/api/get (exact) + /search (fallback), parses LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries, 1-hour TTL). Negative-caches "not found" so obscure tracks don't re-hit the API every render. - NowPlayingState gains syncedLyrics + currentLyricIndex @Published. applyAdapterUpdate detects track changes and refreshes lyrics detached; the 1s playback timer updates currentLyricIndex. - DesktopLyricsViews replaces the placeholder text with real lyric text from syncedLyrics[currentLyricIndex ± 1]. Falls back to sensible dots when no lyrics loaded / instrumentals. Bonus · robust vinyl spin via TimelineView withAnimation(.linear.repeatForever) loses the animation when SwiftUI re-creates the view (window hide/show, style switch). Replaced with TimelineView driven by wall-clock — angle = (elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses correctly via `paused: !isPlaying`. Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll) MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:55:04 +00:00
// Detect track change BEFORE we overwrite the fields.
let trackChanged = (self.title != info.title) || (self.artist != info.artist)
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
self.title = info.title
self.artist = info.artist
self.album = info.album
self.duration = info.duration
self.elapsedTime = info.elapsedTime
self.isPlaying = info.isPlaying
if let art = info.artwork {
self.albumArt = art
}
// Source name from bundle id for the UI chip. Apple Music "Apple Music"
// etc. Unknown bundle ids fall back to generic "System Media".
self.sourceBundleId = info.bundleIdentifier
self.sourceName = Self.humanReadableSource(bundleId: info.bundleIdentifier)
self.lastChromeTabURL = ""
self.stickySource = .mediaRemoteAdapter
self.updatePlaybackTimer()
self.rearmPoll()
v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl Critical fix · adapter stream was silently empty v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The `stream` mode wraps every emit as: {"type":"data","diff":<bool>,"payload":{title,...}} but Swift was decoding as AdapterStreamPayload directly (the shape used by `get`, which is flat). Result: every `stream rx` had title="" because the real data was nested inside payload, so hasTrack was always false and onUpdate never fired. Users saw "nothing playing" even while Apple Music was running. New AdapterStreamEnvelope decodes the wrapper, extracts payload, and also honours `diff: false` to reset currentInfo before merging (stale fields from the previous track were otherwise leaking). Added bootstrap path · cold start with music already playing When the adapter subprocess is spawned AFTER Apple Music is already playing, the stream's initial emit can be an empty baseline. A parallel one-shot `perl adapter.pl get` at spawn+300ms catches the current track immediately. Added file-based debug log at /tmp/mio-plugin-music-debug.log NSLog / os_log are unreliably filtered on macOS 15, and we can't attach Xcode to a plugin loaded from a signed host. A line-oriented log at a fixed path is the one channel that's always readable post- mortem. Lines include stream rx / bootstrap / parse failures. Real lyrics · LRCLIB integration - New LyricsService fetches synced lyrics via https://lrclib.net/api/get (exact) + /search (fallback), parses LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries, 1-hour TTL). Negative-caches "not found" so obscure tracks don't re-hit the API every render. - NowPlayingState gains syncedLyrics + currentLyricIndex @Published. applyAdapterUpdate detects track changes and refreshes lyrics detached; the 1s playback timer updates currentLyricIndex. - DesktopLyricsViews replaces the placeholder text with real lyric text from syncedLyrics[currentLyricIndex ± 1]. Falls back to sensible dots when no lyrics loaded / instrumentals. Bonus · robust vinyl spin via TimelineView withAnimation(.linear.repeatForever) loses the animation when SwiftUI re-creates the view (window hide/show, style switch). Replaced with TimelineView driven by wall-clock — angle = (elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses correctly via `paused: !isPlaying`. Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll) MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:55:04 +00:00
if trackChanged {
// Drop stale lyrics and fetch fresh ones from LRCLIB.
self.syncedLyrics = []
self.currentLyricIndex = -1
refreshLyrics()
} else {
// Same track, but possibly a seek or pause/resume recompute
// the current-lyric index immediately instead of waiting for
// the next playback-timer tick.
updateCurrentLyricIndex()
}
}
/// Pull synced lyrics from LRCLIB for the current track and publish.
/// Runs off the main actor (network I/O) but the @Published update
/// hops back to main. No-op when title/artist are missing spares
/// LRCLIB a pointless round-trip.
private func refreshLyrics() {
let t = title, a = artist, al = album, d = duration
guard !t.isEmpty, !a.isEmpty else { return }
Task.detached(priority: .utility) { [weak self] in
let lines = await LyricsService.fetch(
artist: a, title: t, album: al, duration: d
)
await MainActor.run {
guard let self else { return }
// Only adopt if the user hasn't moved on to another track
// during the network call (LRCLIB can take 1-2s on misses).
guard self.title == t, self.artist == a else { return }
self.syncedLyrics = lines
self.currentLyricIndex = -1
self.updateCurrentLyricIndex()
}
}
}
/// Find the lyric line whose timestamp current elapsedTime. Binary
/// search since lines are sorted. Only publishes if the index actually
/// changed prevents unnecessary SwiftUI redraws every tick.
func updateCurrentLyricIndex() {
guard !syncedLyrics.isEmpty else {
if currentLyricIndex != -1 { currentLyricIndex = -1 }
return
}
var newIndex = -1
// Linear scan is fine typical lyric line counts are 3080.
for (i, line) in syncedLyrics.enumerated() {
if elapsedTime >= line.timestamp {
newIndex = i
} else {
break
}
}
if newIndex != currentLyricIndex {
currentLyricIndex = newIndex
}
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
}
private static func humanReadableSource(bundleId: String) -> String {
switch bundleId {
case "com.apple.Music": return "Apple Music"
case "com.spotify.client": return "Spotify"
case "com.google.Chrome": return "Chrome"
case "com.apple.Safari": return "Safari"
case "com.microsoft.edgemac": return "Edge"
case "com.apple.podcasts": return "Podcasts"
case "com.apple.tv": return "Apple TV"
default:
return bundleId.components(separatedBy: ".").last?.capitalized ?? "System Media"
}
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
private func apply(mediaRemote info: MediaRemoteInfo) {
self.title = info.title
self.artist = info.artist
self.album = info.album
self.duration = info.duration
self.elapsedTime = info.elapsedTime
self.isPlaying = info.isPlaying
self.albumArt = info.artwork
self.sourceName = "System Media"
self.sourceBundleId = info.bundleIdentifier
self.lastChromeTabURL = ""
}
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
}
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
self.title = info.title
self.artist = info.artist
self.album = info.album
self.duration = info.duration
self.elapsedTime = info.elapsedTime
self.isPlaying = info.isPlaying
self.sourceName = info.source
self.sourceBundleId = info.bundleId
self.lastChromeTabURL = ""
}
private func apply(chrome info: ChromeTrackInfo) {
self.title = info.title
self.artist = info.artist
self.album = ""
self.duration = info.duration
self.elapsedTime = info.elapsedTime
self.isPlaying = info.isPlaying
self.sourceName = info.sourceName
self.sourceBundleId = ChromeWebSource.bundleId
self.lastChromeTabURL = info.tabURL
}
private func clearTrack() {
title = ""
artist = ""
album = ""
albumArt = nil
isPlaying = false
duration = 0
elapsedTime = 0
sourceName = ""
sourceBundleId = ""
}
// MARK: - Playback timer
private func updatePlaybackTimer() {
playbackTimer?.invalidate()
playbackTimer = nil
guard isPlaying, duration > 0 else { return }
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, self.isPlaying else { return }
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl Critical fix · adapter stream was silently empty v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The `stream` mode wraps every emit as: {"type":"data","diff":<bool>,"payload":{title,...}} but Swift was decoding as AdapterStreamPayload directly (the shape used by `get`, which is flat). Result: every `stream rx` had title="" because the real data was nested inside payload, so hasTrack was always false and onUpdate never fired. Users saw "nothing playing" even while Apple Music was running. New AdapterStreamEnvelope decodes the wrapper, extracts payload, and also honours `diff: false` to reset currentInfo before merging (stale fields from the previous track were otherwise leaking). Added bootstrap path · cold start with music already playing When the adapter subprocess is spawned AFTER Apple Music is already playing, the stream's initial emit can be an empty baseline. A parallel one-shot `perl adapter.pl get` at spawn+300ms catches the current track immediately. Added file-based debug log at /tmp/mio-plugin-music-debug.log NSLog / os_log are unreliably filtered on macOS 15, and we can't attach Xcode to a plugin loaded from a signed host. A line-oriented log at a fixed path is the one channel that's always readable post- mortem. Lines include stream rx / bootstrap / parse failures. Real lyrics · LRCLIB integration - New LyricsService fetches synced lyrics via https://lrclib.net/api/get (exact) + /search (fallback), parses LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries, 1-hour TTL). Negative-caches "not found" so obscure tracks don't re-hit the API every render. - NowPlayingState gains syncedLyrics + currentLyricIndex @Published. applyAdapterUpdate detects track changes and refreshes lyrics detached; the 1s playback timer updates currentLyricIndex. - DesktopLyricsViews replaces the placeholder text with real lyric text from syncedLyrics[currentLyricIndex ± 1]. Falls back to sensible dots when no lyrics loaded / instrumentals. Bonus · robust vinyl spin via TimelineView withAnimation(.linear.repeatForever) loses the animation when SwiftUI re-creates the view (window hide/show, style switch). Replaced with TimelineView driven by wall-clock — angle = (elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses correctly via `paused: !isPlaying`. Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll) MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:55:04 +00:00
self.updateCurrentLyricIndex()
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
if self.elapsedTime >= self.duration {
self.playbackTimer?.invalidate()
self.playbackTimer = nil
}
}
}
}
// MARK: - Controls
func togglePlayPause() {
// Optimistically flip so the UI feels responsive.
let shouldPlay = !isPlaying
isPlaying = shouldPlay
updatePlaybackTimer()
rearmPoll() // isPlaying flipped maybe change poll cadence
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
switch stickySource {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case .spotify:
SpotifyAppleScript.togglePlay()
case .appleMusic:
AppleMusicAppleScript.togglePlay()
case .chrome:
let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
Task { _ = await ChromeWebSource.togglePlay(shouldPlay: shouldPlay, preferredURL: url) }
case .mediaRemote, .none:
mediaRemote.sendCommand(.togglePlayPause)
}
// Confirm from the real source after a short delay.
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
scheduleRefresh(after: 0.1)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
func nextTrack() {
switch stickySource {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case .spotify:
SpotifyAppleScript.next()
case .appleMusic:
AppleMusicAppleScript.next()
case .chrome:
// Chrome has no generic "next" control across sites.
mediaRemote.sendCommand(.nextTrack)
case .mediaRemote, .none:
mediaRemote.sendCommand(.nextTrack)
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
scheduleRefresh(after: 0.1)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
func previousTrack() {
switch stickySource {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case .spotify:
SpotifyAppleScript.previous()
case .appleMusic:
AppleMusicAppleScript.previous()
case .chrome:
mediaRemote.sendCommand(.previousTrack)
case .mediaRemote, .none:
mediaRemote.sendCommand(.previousTrack)
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
scheduleRefresh(after: 0.1)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
func seek(to time: TimeInterval) {
let clamped = max(0, min(time, duration > 0 ? duration : time))
elapsedTime = clamped
updatePlaybackTimer()
switch stickySource {
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate Ports the MediaRemoteAdapter pattern from Atoll (github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made our previous MediaRemoteSource return empty dicts and forced us onto slow-path AppleScript polling. This commit bundles Jonas van den Berg's MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl and runs them as a subprocess — the framework links against Apple's MR in a way that skips the caller-side entitlement check, so we get the full now-playing payload (title, artist, album, duration, elapsed, isPlaying, artwork, bundleIdentifier) pushed to us in real time. Bundle additions (~500KB total): - Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e) - Resources/mediaremote-adapter.pl - LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution New source: MediaRemoteAdapterSource.swift - Spawns /usr/bin/perl with minimal env (PATH + LANG only). - FileHandle.readabilityHandler ingests newline-delimited JSON stream from stdout, parses via Codable AdapterStreamPayload, merges diffs into persistent MediaRemoteInfo so playbackRate-only payloads don't erase title/artist. - Artwork base64 decoded via Data default strategy. - Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes within 60s → fall back to legacy chain. - Transport controls (togglePlay/next/prev/seek) via short-lived one-shot `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next, 5=prev. seek takes microseconds. NowPlayingState wiring: - New sticky kind `.mediaRemoteAdapter`, highest priority. - `applyAdapterUpdate(_:)` publishes directly (no router pass). - `routeSources` short-circuits when adapter is sticky + has data — subprocess pushes fresh data on every change, polling would be pure waste. - `adaptivePollInterval()` returns 30s for adapter (safety net only). - `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil from pull-fetch so the sticky fast-path falls through to parallel probing if subprocess is dead). - `stop()` terminates the subprocess cleanly. - Transport controls route to adapter.sendCommand() / adapter.seek() when it's the sticky source. Build: - build.sh copies Resources/ into Contents/Resources with preserved exec bits on the framework binary + Perl script. - `codesign --force --deep --sign -` re-signs the whole tree ad-hoc so the nested framework inherits our identity and Gatekeeper loads it without complaint. - Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the latency win: Apple Music track switches now visible <100ms vs prior 800ms adaptive-poll worst case. Security audit (done before bundling): - Perl script: strict + warnings, whitelisted function names, no shell-out, no network I/O, params passed to framework via ENV (no string concat). Safe. - Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter). --deep re-sign with our identity replaces the original ad-hoc cert so signature validation passes locally and in Gatekeeper. - Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited secrets. - Explicit Process arguments array — no shell interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
case .mediaRemoteAdapter:
mediaRemoteAdapter?.seek(clamped)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
case .spotify:
SpotifyAppleScript.seek(to: clamped)
case .appleMusic:
AppleMusicAppleScript.seek(to: clamped)
case .chrome:
let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
Task { _ = await ChromeWebSource.seek(to: clamped, preferredURL: url) }
case .mediaRemote, .none:
mediaRemote.setElapsedTime(clamped)
}
v2.0.4: latency razor — event-driven + running-app gate + parallel probing 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>
2026-04-20 07:02:36 +00:00
scheduleRefresh(after: 0.1)
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
}
private func scheduleRefresh(after delay: TimeInterval) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
self.refresh()
}
}
}
// MARK: - Shared AppleScript + network helpers (module-level)
/// Background queue dedicated to NSAppleScript. NSAppleScript is documented
/// as thread safe only within a single thread, so we keep all invocations
/// serial on this queue and marshal results back via async continuations.
private let appleScriptQueue = DispatchQueue(
label: "mio-plugin-music.applescript",
qos: .userInitiated
)
/// Execute an AppleScript source string asynchronously. Returns the string
/// value of the result or nil on error. Error numbers are split into:
/// -600 : application is not running (normal, silent)
/// -1728 : Apple Event descriptor error (often benign, silent)
/// other : logged via NSLog with a tag
func runAppleScript(_ source: String, tag: String) async -> String? {
await withCheckedContinuation { continuation in
appleScriptQueue.async {
var errorDict: NSDictionary?
guard let script = NSAppleScript(source: source) else {
continuation.resume(returning: nil)
return
}
let result = script.executeAndReturnError(&errorDict)
if let errorDict {
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
// Silence known-expected error codes:
// -600 = application not running
// -1712 = errAETimeout (our `with timeout of N seconds` firing)
// -1728 = AEError, generic Apple Event descriptor issue
if num != -600 && num != -1712 && num != -1728 {
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
}
continuation.resume(returning: nil)
return
}
continuation.resume(returning: result.stringValue)
}
}
}
/// Run an AppleScript where we don't care about the return value (transport
/// controls). Errors still respect the -600 / -1728 silence list.
func runAppleScriptFireAndForget(_ source: String, tag: String) {
appleScriptQueue.async {
var errorDict: NSDictionary?
guard let script = NSAppleScript(source: source) else { return }
_ = script.executeAndReturnError(&errorDict)
if let errorDict {
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
if num != -600 && num != -1728 {
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
}
}
}
}
/// Download image data asynchronously. Returns nil on any failure.
func downloadImage(from url: URL) async -> NSImage? {
await withCheckedContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, _, _ in
guard let data, let image = NSImage(data: data) else {
continuation.resume(returning: nil)
return
}
continuation.resume(returning: image)
}.resume()
}
}