Compare commits

..

7 Commits
v2.0.0 ... main

Author SHA1 Message Date
徐翔宇
fbc64caec5 v2.2.2: fix UI hang on plugin install — bootstrap off main queue
Symptom: installing v2.2.1 caused Mio Island to freeze ("卡崩") on launch.
Not a true crash, just the main runloop stuck long enough to trip the
"app not responding" state.

Root cause: MediaRemoteAdapterSource.spawn() scheduled bootstrapGet()
on `DispatchQueue.main.asyncAfter(+0.3)`. bootstrapGet runs a Perl
subprocess that dlopens MediaRemoteAdapter.framework then calls
`get` — that cold path takes 500ms to 1s in the worst case. During
that entire window, `proc.waitUntilExit()` blocks the main thread.
No UI events drain, SwiftUI drops frames, window looks hung.

Fix:
- Move the `DispatchQueue.main.asyncAfter` to
  `DispatchQueue.global(qos: .userInitiated).asyncAfter` so the Perl
  cold path runs on a background queue.
- Since `currentInfo` is now mutated from both queues (bg in bootstrap,
  main in parseLine/stream), hop the merge + onUpdate back onto main
  after we parse the JSON on bg. Single writer, no data race.
- parseLine is unchanged — still runs on main via the FileHandle
  readabilityHandler hop.

Verified 30s alive, debug log shows bootstrap + stream rx both
emitting correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:05:01 +08:00
徐翔宇
69776ecec2 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-21 07:55:04 +08:00
徐翔宇
d5934b06b0 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 15:19:40 +08:00
徐翔宇
113dd31275 v2.0.5: adaptive polling — Apple Music 0.8s when playing
v2.0.4's 15s safety-net poll broke Apple Music latency because macOS 14+
Music.app doesn't reliably broadcast com.apple.Music.playerInfo, and
MediaRemote is 15.4-gated. With no event source actually firing, 15s
between polls = 15s track-change lag.

Poll interval is now computed from stickySource + isPlaying:
- Apple Music playing     → 0.8s (no reliable event source)
- Chrome playing          → 1.2s (no event source, web audio too)
- Spotify playing         → 3.0s (playerInfo broadcast is fast,
                                   poll is just backup)
- MediaRemote playing     → 3.0s (MR notifications cover it)
- Idle / nothing playing  → 10.0s (NSWorkspace launch observer will
                                    wake us instantly)

rearmPoll() is called after every stickySource change + after the
optimistic isPlaying flip in togglePlayPause, so the cadence adapts
within a single RunLoop tick. Cheap: if the new interval equals the
current one within 0.01s, skip the Timer re-alloc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:07:56 +08:00
徐翔宇
336b2266e8 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 15:02:36 +08:00
徐翔宇
63885fe121 v2.0.3: compact panel via host size hint + Apple Music artwork + faster poll
Host-facing:
- Info.plist requests a 440x340 expanded panel via the new
  MioPluginPreferredWidth/MioPluginPreferredHeight keys (MioIsland
  v2.1.8+). Old hosts ignore the keys and use their default.

UI:
- Fix vertical stretching of the playing card. Outer ZStack now centers
  children instead of wrapping in a maxHeight:.infinity frame which was
  letting an inner Spacer propagate fill-height up to the top-level VStack.
- Hero HStack clipped to album art height (128pt) so the meta column
  can't bleed a fill-height hint upward either.

Data:
- Apple Music artwork is now fetched via a temp file (write artwork data
  of current track to /tmp, load NSImage from disk). First-class cover art
  instead of the generic music.note placeholder.
- apply(appleScript:) clears albumArt when the track identity changes so
  the next refresh reloads cover art for the new track.

Latency:
- Poll interval 3s → 1.5s. Track changes typically reflect within 2s.
- Also subscribe to the legacy com.apple.iTunes.playerInfo distributed
  notification in addition to com.apple.Music.playerInfo — some builds of
  Music.app still emit the iTunes name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:42:14 +08:00
徐翔宇
c67ddd0024 v2.0.1: compact UI + AppleScript timeouts
UI polish (ExpandedView rewrite):
- Horizontal hero row: 128×128 album art on the left, title/artist/
  album + source badge on the right. Half the vertical footprint of
  v2.0.0 at the same info density.
- Dropped the "NOW PLAYING" eyebrow (redundant with the source badge).
- Tightened outer padding 28 → 20, inter-section spacing 22-28 → 16.
- Play button 56 → 48, prev/next 44 → 36; still 44pt tap targets via
  the invisible hover frame.

AppleScript timeout fix (the real bug, unrelated to UI):
- Every fetch() script now wraps the `tell application` block in
  `with timeout of N seconds` (2s for Spotify/Music, 3s for Chrome).
- Music.app hanging was stalling the entire source router for 120s
  (default AppleEvent timeout), freezing the UI on stale Spotify data.
- runAppleScript() suppresses error -1712 (errAETimeout) alongside
  existing -600 / -1728 — expected, not noisy.

Info.plist: CFBundleShortVersionString 2.0.0 → 2.0.1,
CFBundleVersion 2 → 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:09:29 +08:00
22 changed files with 2473 additions and 154 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ build/
*.swiftmodule
*.dSYM
.build/
icon/

View File

@ -15,10 +15,21 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.0</string>
<string>2.2.2</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>11</string>
<key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string>
<!--
Optional size hint for the expanded plugin panel.
Host reads these on plugin load and caps the expanded area to the
requested dimensions instead of the default ~620x780. Both keys
must be present. Range: width 280-1200, height 180-900. Values
outside that range are ignored and the host falls back to default.
-->
<key>MioPluginPreferredWidth</key>
<integer>440</integer>
<key>MioPluginPreferredHeight</key>
<integer>340</integer>
</dict>
</plist>

46
LICENSE-THIRD-PARTY.md Normal file
View File

@ -0,0 +1,46 @@
# Third-Party Components
This plugin bundles third-party components under `Resources/`. Each
component's copyright and license text is preserved below.
## MediaRemoteAdapter.framework + mediaremote-adapter.pl
- Source: https://github.com/Ebullioscopic/Atoll/tree/main/mediaremote-adapter
- Copyright (c) 2025 Jonas van den Berg
- License: BSD 3-Clause
- Purpose: Bypasses the macOS 15.4+ entitlement gate on
`MRMediaRemoteGetNowPlayingInfo` by exposing an alternate adapter
symbol table through a dedicated private framework wrapper. We
invoke the bundled `mediaremote-adapter.pl` as a subprocess and
consume its JSON stream.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1 @@
Versions/Current/MediaRemoteAdapter

View File

@ -0,0 +1 @@
Versions/Current/Resources

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>MediaRemoteAdapter</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>com.vandenbe.MediaRemoteAdapter</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>MediaRemoteAdapter</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<key>CSResourcesFileMapped</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
M6AF1VWVJ1A/DSliCSjg170FqsY=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1 @@
A

257
Resources/mediaremote-adapter.pl Executable file
View File

@ -0,0 +1,257 @@
#!/usr/bin/perl
# Copyright (c) 2025 Jonas van den Berg
# This file is licensed under the BSD 3-Clause License.
# For usage information read below or run the script without arguments.
use strict;
use warnings;
use DynaLoader;
use File::Spec;
use File::Basename;
sub print_help() {
print <<'HELP';
Usage:
mediaremote-adapter.pl FRAMEWORK_PATH [NOWPLAYING_CLIENT_PATH] [FUNCTION [PARAMS|OPTIONS...]]
FRAMEWORK_PATH:
Absolute path to MediaRemoteAdapter.framework
NOWPLAYING_CLIENT_PATH (optional):
Path to the NowPlayingTestClient executable (used for test mode)
FUNCTION:
stream Streams now playing information (as diff by default)
get Prints now playing information once with all available metadata
send Sends a command to the now playing application
seek Seeks to a specific timeline position
shuffle Sets the shuffle mode
repeat Sets the repeat mode
speed Sets the playback speed
PARAMS:
send(command)
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
seek(position)
position: The timeline position in microseconds
shuffle(mode)
mode: The shuffle mode
repeat(mode)
mode: The repeat mode
speed(speed)
speed: The playback speed
OPTIONS:
get
--now: Sets "elapsedTime" to the current elapsed time. By default,
this value is the elapsed time at the time of the given "timestamp"
stream
--no-diff: Disable diffing and always dump all metadata
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
get, stream
--micros: Replaces the following time keys with microsecond equivalents
"duration" -> "durationMicros"
"elapsedTime" -> "elapsedTimeMicros"
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
--human-readable, -h: Makes values human-readable. Use only for debugging.
The JSON output is pretty-printed and the following keys are adapted:
"artworkData" -> Binary data is truncated to a shorter representation
Examples (script name and framework path omitted):
stream --no-diff --debounce=100
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
HELP
exit 0;
}
if (!defined $ARGV[1]) {
print_help();
}
sub fail {
my ($error) = @_;
print STDERR "$error\n";
exit 1;
}
fail "Framework path not provided" unless @ARGV >= 1;
my $framework_path = shift @ARGV;
# Optionally accept NOWPLAYING_CLIENT path as second argument
my $maybe_helper_path = $ARGV[0] // '';
if ($maybe_helper_path =~ m{NowPlayingTestClient} || $maybe_helper_path =~ m{/}) {
my $helper_path = shift @ARGV;
$ENV{NOWPLAYING_CLIENT} = $helper_path;
}
my $framework_basename = File::Basename::basename($framework_path);
fail "Provided path is not a framework: $framework_path"
unless $framework_basename =~ s/\.framework$//;
my $framework = File::Spec->catfile($framework_path, $framework_basename);
fail "Framework not found at $framework" unless -e $framework;
my $handle = DynaLoader::dl_load_file($framework, 0)
or fail "Failed to load framework: $framework";
my $function_name = shift @ARGV or fail "Missing function name";
fail "Invalid function name: '$function_name'"
unless $function_name eq "stream"
|| $function_name eq "get"
|| $function_name eq "send"
|| $function_name eq "seek"
|| $function_name eq "shuffle"
|| $function_name eq "repeat"
|| $function_name eq "speed"
|| $function_name eq "test";
sub parse_options {
my ($start_index) = @_;
my %arg_map;
my $i = $start_index;
while ($i <= $#ARGV) {
my $arg = $ARGV[$i];
if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) {
my $key = $1;
my $value = defined $2 ? $2 : undef;
$arg_map{$key} = $value;
splice @ARGV, $i, 1;
}
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
my @flags = split //, $1;
$arg_map{$_} = undef for @flags;
splice @ARGV, $i, 1;
}
else {
$i++;
}
}
return \%arg_map;
}
sub env_func {
my $symbol_name = shift;
return "${symbol_name}_env";
}
sub set_env_param {
my ($func, $index, $name, $value) = @_;
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
}
sub set_env_option_unsafe {
my ($name, $value) = @_;
$name =~ s/-/_/g;
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
}
sub set_env_option {
my ($options, $key) = @_;
my $value = $options->{$key};
if (defined $value) {
fail "Unexpected value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
sub set_env_option_value {
my ($options, $key) = @_;
my $value = $options->{$key};
if (!defined $value) {
fail "Missing value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
my $symbol_name = "adapter_$function_name";
if ($function_name eq "send") {
my $id = shift @ARGV;
fail "Missing ID for '$function_name' command" unless defined $id;
set_env_param($symbol_name, 0, "command", "$id");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "stream") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "no-diff") {
set_env_option($options, $key);
}
elsif ($key eq "debounce") {
set_env_option_value($options, $key);
}
elsif ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "get") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "now") {
set_env_option($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "seek") {
my $position = shift @ARGV;
fail "Missing position for '$function_name' command" unless defined $position;
set_env_param($symbol_name, 0, "position", "$position");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "shuffle") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "repeat") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "speed") {
my $speed = shift @ARGV;
fail "Missing speed for '$function_name' command" unless defined $speed;
set_env_param($symbol_name, 0, "speed", "$speed");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "test") {
$symbol_name = "_adapter_is_it_broken_yet";
}
if (defined shift @ARGV) {
fail "Too many arguments";
}
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
or fail "Symbol '$symbol_name' not found in $framework";
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
eval {
no strict "refs";
&{"main::$function_name"}();
};
if ($@) {
fail "Error executing $function_name: $@";
}

View File

@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin {
var id: String { "music-player" }
var name: String { "Music Player" }
var icon: String { "music.note" }
var version: String { "2.0.0" }
var version: String { "2.0.3" }
func activate() {
NSLog("[mio-plugin-music] activate")

View File

@ -31,6 +31,10 @@ import Combine
enum NowPlayingSourceKind: String {
case none
/// 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
case mediaRemote
case spotify
case appleMusic
@ -71,6 +75,15 @@ final class NowPlayingState: ObservableObject {
/// 使" hint.
@Published var chineseAppDetected: String?
/// 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
// MARK: - Derived
var progress: Double {
@ -92,6 +105,11 @@ final class NowPlayingState: ObservableObject {
// MARK: - Private
private let mediaRemote = MediaRemoteSource()
/// 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()
private var pollTimer: Timer?
private var playbackTimer: Timer?
private var cancellables = Set<AnyCancellable>()
@ -100,6 +118,20 @@ final class NowPlayingState: ObservableObject {
private var isRunning = false
private var refreshInFlight = false
/// 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] = []
private init() {}
// MARK: - Lifecycle
@ -116,6 +148,17 @@ final class NowPlayingState: ObservableObject {
Task { @MainActor in self?.refresh() }
}
// 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()
}
// Observe Spotify distributed notifications for instant reaction.
DistributedNotificationCenter.default().addObserver(
self,
@ -124,13 +167,56 @@ final class NowPlayingState: ObservableObject {
object: nil
)
// Observe Apple Music similarly.
// Observe Apple Music. macOS 15+ Music.app emits
// com.apple.Music.playerInfo; older iTunes emitted
// com.apple.iTunes.playerInfo. Register both so track changes are
// picked up instantly regardless of which one the current build
// broadcasts.
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
object: nil
)
// 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]
startPolling()
refresh()
@ -146,6 +232,14 @@ final class NowPlayingState: ObservableObject {
playbackTimer?.invalidate()
playbackTimer = nil
DistributedNotificationCenter.default().removeObserver(self)
let wsCenter = NSWorkspace.shared.notificationCenter
for token in workspaceObservers {
wsCenter.removeObserver(token)
}
workspaceObservers.removeAll()
mediaRemoteAdapter?.stop()
}
@objc private func spotifyStateChanged() {
@ -159,8 +253,51 @@ final class NowPlayingState: ObservableObject {
// 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 {
// 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
}
pollTimer?.invalidate()
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
currentPollInterval = newInterval
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refresh() }
}
}
@ -184,43 +321,193 @@ final class NowPlayingState: ObservableObject {
}
private func routeSources(allowAppleScript: Bool) async {
// Build the order: sticky source first, then the default chain.
let defaultOrder: [NowPlayingSourceKind] = [
.mediaRemote, .spotify, .appleMusic, .chrome
]
var order: [NowPlayingSourceKind] = []
if stickySource != .none { order.append(stickySource) }
for kind in defaultOrder where kind != stickySource {
order.append(kind)
// 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
}
for kind in order {
// Skip AppleScript sources when the host cannot grant permission.
if !allowAppleScript, kind != .mediaRemote { continue }
// 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
if let used = await tryFetch(kind) {
// 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
}
// 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) {
await MainActor.run {
self.stickySource = used
self.updatePlaybackTimer()
self.rearmPoll()
}
return
}
}
// 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()
}
return
}
if let info = sp, !info.title.isEmpty {
await MainActor.run {
self.apply(appleScript: info)
self.stickySource = .spotify
self.updatePlaybackTimer()
self.rearmPoll()
}
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()
}
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()
}
if let artURL = info.artworkURL, let url = URL(string: artURL) {
if let image = await downloadImage(from: url) {
await MainActor.run { self.albumArt = image }
}
}
return
}
// Nothing returned a hit; clear state.
await MainActor.run {
self.clearTrack()
self.stickySource = .none
self.updatePlaybackTimer()
self.rearmPoll()
}
}
/// 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
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
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
}
/// 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
case .mediaRemoteAdapter:
// Push-only source; pull-fetch is a no-op.
return nil
case .mediaRemote:
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
Task { @MainActor in
@ -242,6 +529,12 @@ final class NowPlayingState: ObservableObject {
case .appleMusic:
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
await MainActor.run { self.apply(appleScript: info) }
// Apple Music doesn't expose an artwork URL via AppleScript;
// we dump the raw bytes to /tmp and reload. Only refetch when
// the track identity actually changes to avoid hammering disk.
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return .appleMusic
case .chrome:
@ -258,6 +551,103 @@ final class NowPlayingState: ObservableObject {
// MARK: - Apply
/// 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) {
// Detect track change BEFORE we overwrite the fields.
let trackChanged = (self.title != info.title) || (self.artist != info.artist)
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()
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
}
}
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"
}
}
private func apply(mediaRemote info: MediaRemoteInfo) {
self.title = info.title
self.artist = info.artist
@ -272,6 +662,11 @@ final class NowPlayingState: ObservableObject {
}
private func apply(appleScript info: AppleScriptTrackInfo) {
// Track changed drop cached artwork so the source can refetch
// (Spotify does URL-based, Apple Music does raw-bytes-via-temp-file).
if self.title != info.title || self.artist != info.artist {
self.albumArt = nil
}
self.title = info.title
self.artist = info.artist
self.album = info.album
@ -317,6 +712,7 @@ final class NowPlayingState: ObservableObject {
Task { @MainActor in
guard let self, self.isPlaying else { return }
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
self.updateCurrentLyricIndex()
if self.elapsedTime >= self.duration {
self.playbackTimer?.invalidate()
self.playbackTimer = nil
@ -332,8 +728,11 @@ final class NowPlayingState: ObservableObject {
let shouldPlay = !isPlaying
isPlaying = shouldPlay
updatePlaybackTimer()
rearmPoll() // isPlaying flipped maybe change poll cadence
switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
case .spotify:
SpotifyAppleScript.togglePlay()
case .appleMusic:
@ -346,11 +745,13 @@ final class NowPlayingState: ObservableObject {
}
// Confirm from the real source after a short delay.
scheduleRefresh(after: 0.3)
scheduleRefresh(after: 0.1)
}
func nextTrack() {
switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
case .spotify:
SpotifyAppleScript.next()
case .appleMusic:
@ -361,11 +762,13 @@ final class NowPlayingState: ObservableObject {
case .mediaRemote, .none:
mediaRemote.sendCommand(.nextTrack)
}
scheduleRefresh(after: 0.3)
scheduleRefresh(after: 0.1)
}
func previousTrack() {
switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
case .spotify:
SpotifyAppleScript.previous()
case .appleMusic:
@ -375,7 +778,7 @@ final class NowPlayingState: ObservableObject {
case .mediaRemote, .none:
mediaRemote.sendCommand(.previousTrack)
}
scheduleRefresh(after: 0.3)
scheduleRefresh(after: 0.1)
}
func seek(to time: TimeInterval) {
@ -384,6 +787,8 @@ final class NowPlayingState: ObservableObject {
updatePlaybackTimer()
switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.seek(clamped)
case .spotify:
SpotifyAppleScript.seek(to: clamped)
case .appleMusic:
@ -395,7 +800,7 @@ final class NowPlayingState: ObservableObject {
mediaRemote.setElapsedTime(clamped)
}
scheduleRefresh(after: 0.3)
scheduleRefresh(after: 0.1)
}
private func scheduleRefresh(after delay: TimeInterval) {
@ -432,7 +837,11 @@ func runAppleScript(_ source: String, tag: String) async -> String? {
let result = script.executeAndReturnError(&errorDict)
if let errorDict {
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
if num != -600 && num != -1728 {
// 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 {
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
}

View File

@ -12,16 +12,32 @@
import AppKit
enum AppleMusicAppleScript {
private static let bundleId = "com.apple.Music"
static let bundleId = "com.apple.Music"
private static let sourceName = "Apple Music"
/// Fast check: is Music.app actually running? When false, skip
/// AppleScript the 2s `with timeout` still trips but that's two
/// wasted seconds per refresh when the user doesn't use Apple Music.
static var isRunning: Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == bundleId
}
}
// MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? {
// Music.app occasionally stalls its AppleEvent handler (observed in
// macOS 15.x when the app is mid-sync). Without an explicit timeout
// each fetch inherits the 120-second default, which freezes the whole
// source router for 2 minutes. `with timeout of 2 seconds` raises
// errAETimeout (-1712) if Music doesn't respond quickly, and our
// Swift layer turns that into nil so the router can move on.
let script = """
tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING"
end tell
with timeout of 2 seconds
tell application "Music"
if player state is playing or player state is paused then
set trackName to name of current track
@ -36,6 +52,7 @@ enum AppleMusicAppleScript {
return "NOT_PLAYING"
end if
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "music") else { return nil }
@ -57,6 +74,45 @@ enum AppleMusicAppleScript {
)
}
// MARK: - Artwork
/// Apple Music stores artwork as embedded raw data (PNG/JPEG) rather than
/// a URL. The cheapest way to pull it via AppleScript is to write the
/// bytes to a temp file and load NSImage from it. The script writes to
/// /tmp/mio-apple-music-art.dat (fixed path overwrites each call).
static func fetchArtwork() async -> NSImage? {
let tmpPath = "/tmp/mio-plugin-music-current-art.dat"
let script = """
tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING"
end tell
with timeout of 3 seconds
tell application "Music"
if player state is stopped then return "STOPPED"
try
set artData to data of artwork 1 of current track
set f to open for access POSIX file "\(tmpPath)" with write permission
set eof f to 0
write artData to f
close access f
return "OK"
on error errMsg
try
close access POSIX file "\(tmpPath)"
end try
return "NO_ARTWORK"
end try
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "music-art"),
raw == "OK" else {
return nil
}
let url = URL(fileURLWithPath: tmpPath)
return await Task.detached { NSImage(contentsOf: url) }.value
}
// MARK: - Controls
static func togglePlay() {

View File

@ -31,13 +31,24 @@ struct ChromeTrackInfo {
enum ChromeWebSource {
static let bundleId = "com.google.Chrome"
/// Fast check: is Chrome running? JS-injection probing costs ~200ms
/// even on a hot path; skip entirely when Chrome isn't running.
static var isRunning: Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == bundleId
}
}
// MARK: - Fetch
static func fetch() async -> ChromeTrackInfo? {
// Iterating N tabs × JS injection is O(N) and can be slow with many
// tabs; cap at 3 seconds so the router doesn't stall the whole cycle.
let script = """
tell application "System Events"
if not (exists process "Google Chrome") then return "NOT_RUNNING"
end tell
with timeout of 3 seconds
tell application "Google Chrome"
set playingTitle to ""
set playingURL to ""
@ -70,6 +81,7 @@ enum ChromeWebSource {
if playingURL is not "" then return "PLAYING_TAB||" & playingTitle & "||" & playingURL & "||" & playingInfo
return "NOT_FOUND"
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "chrome") else { return nil }

View File

@ -0,0 +1,242 @@
//
// LyricsService.swift
// MioIsland Music Plugin
//
// Fetches synced lyrics from LRCLIB (https://lrclib.net/docs) and parses
// their LRC format into per-line timestamps. Free public API, no auth.
//
// Approach borrowed from Atoll (github.com/Ebullioscopic/Atoll,
// MusicManager.swift:756895). Two lookup endpoints:
//
// /api/get?track_name=&artist_name=&album_name=&duration=
// exact match with all four params; best hit rate when present.
//
// /api/search?track_name=&artist_name=
// fallback text search, returns an array; we take the first.
//
// LRC lines look like "[mm:ss.xx] Lyric line". We regex-extract the
// timestamp + trailing text. Centiseconds optional.
//
// Caching: in-memory LRU keyed by (artist + title + duration-bucket).
// Bucket duration to nearest second so slight float drift between
// MediaRemote and LRCLIB doesn't create separate cache keys. Cache
// size capped at 32 entries plenty for a single listening session.
//
import Foundation
// MARK: - Public types
struct LyricLine: Equatable, Identifiable {
let id = UUID()
let timestamp: TimeInterval
let text: String
}
enum LyricsService {
// MARK: - Cache
/// Cache entry an empty array means "we tried, nothing found".
/// This negative-cache prevents hammering LRCLIB on songs with no
/// lyrics (e.g. instrumentals).
private struct CacheEntry {
let lines: [LyricLine]
let cachedAt: Date
}
private static let cacheQueue = DispatchQueue(
label: "mio-plugin-music.lyrics-cache",
attributes: .concurrent
)
private static var _cache: [String: CacheEntry] = [:]
private static let maxCacheSize = 32
private static let cacheTTL: TimeInterval = 60 * 60 // 1 hour
private static func cacheKey(artist: String, title: String, duration: TimeInterval) -> String {
let bucket = Int(duration.rounded())
return "\(artist.lowercased())|\(title.lowercased())|\(bucket)"
}
private static func lookupCache(key: String) -> [LyricLine]? {
var result: [LyricLine]?
cacheQueue.sync {
if let entry = _cache[key],
Date().timeIntervalSince(entry.cachedAt) < cacheTTL {
result = entry.lines
}
}
return result
}
private static func storeCache(key: String, lines: [LyricLine]) {
cacheQueue.async(flags: .barrier) {
if _cache.count >= maxCacheSize {
// Naive eviction: drop the oldest entry. Perfect LRU
// isn't worth extra bookkeeping for N=32.
if let oldestKey = _cache.min(by: { $0.value.cachedAt < $1.value.cachedAt })?.key {
_cache.removeValue(forKey: oldestKey)
}
}
_cache[key] = CacheEntry(lines: lines, cachedAt: Date())
}
}
// MARK: - Fetch
/// Fetch synced lyrics for the given track. Returns an empty array on
/// "tried and no lyrics found" the caller should treat nil (error)
/// and [] (no lyrics) as distinct states for UX. Safe to call off the
/// main actor; result is not main-isolated.
static func fetch(
artist: String,
title: String,
album: String = "",
duration: TimeInterval = 0
) async -> [LyricLine] {
let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedArtist.isEmpty, !trimmedTitle.isEmpty else { return [] }
let key = cacheKey(artist: trimmedArtist, title: trimmedTitle, duration: duration)
if let cached = lookupCache(key: key) { return cached }
// 1. Exact match (best hit rate when album + duration are known).
if duration > 0 {
if let lines = try? await fetchExact(
artist: trimmedArtist,
title: trimmedTitle,
album: album,
duration: duration
) {
storeCache(key: key, lines: lines)
return lines
}
}
// 2. Search fallback.
if let lines = try? await fetchSearch(artist: trimmedArtist, title: trimmedTitle) {
storeCache(key: key, lines: lines)
return lines
}
storeCache(key: key, lines: [])
return []
}
private static let baseURL = "https://lrclib.net/api"
private struct GetResponse: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private struct SearchResultItem: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private static func fetchExact(
artist: String,
title: String,
album: String,
duration: TimeInterval
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/get")!
comps.queryItems = [
URLQueryItem(name: "artist_name", value: artist),
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "album_name", value: album),
URLQueryItem(name: "duration", value: String(Int(duration.rounded())))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let decoded = try? JSONDecoder().decode(GetResponse.self, from: data) {
if let synced = decoded.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = decoded.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
private static func fetchSearch(
artist: String,
title: String
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/search")!
comps.queryItems = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist)
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let items = try? JSONDecoder().decode([SearchResultItem].self, from: data),
let first = items.first {
if let synced = first.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = first.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
// MARK: - LRC parsing
/// LRC timestamp regex matches [mm:ss] and [mm:ss.xx] (centiseconds
/// optional). Captures three groups: minutes, seconds, centiseconds.
private static let lrcRegex: NSRegularExpression = {
// Force-try here: pattern is static and known-valid at compile time.
// swiftlint:disable:next force_try
try! NSRegularExpression(
pattern: "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]",
options: []
)
}()
static func parseLRC(_ lrc: String) -> [LyricLine] {
var out: [LyricLine] = []
for raw in lrc.components(separatedBy: .newlines) {
let ns = raw as NSString
let range = NSRange(location: 0, length: ns.length)
guard let match = lrcRegex.firstMatch(in: raw, options: [], range: range) else {
continue
}
let minutes = Double(ns.substring(with: match.range(at: 1))) ?? 0
let seconds = Double(ns.substring(with: match.range(at: 2))) ?? 0
let centi: Double = {
let r = match.range(at: 3)
return r.location != NSNotFound ? (Double(ns.substring(with: r)) ?? 0) : 0
}()
let ts = minutes * 60 + seconds + centi / 100.0
let textStart = match.range.location + match.range.length
guard textStart <= ns.length else { continue }
let text = ns.substring(from: textStart).trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { continue }
out.append(LyricLine(timestamp: ts, text: text))
}
return out.sorted { $0.timestamp < $1.timestamp }
}
}

View File

@ -0,0 +1,412 @@
//
// MediaRemoteAdapterSource.swift
// MioIsland Music Plugin
//
// Bypasses the macOS 15.4+ MRMediaRemoteGetNowPlayingInfo entitlement gate
// by running `mediaremote-adapter.pl` (BSD-3-Clause, by Jonas van den Berg)
// as a subprocess. The Perl script DynaLoader-loads the bundled
// MediaRemoteAdapter.framework binary, which in turn links against Apple's
// MediaRemote private framework. Because the entitlement check fires on the
// CALLING symbol which on Apple's side is MR internals, not our process
// the gate is skipped and we get the full now-playing payload.
//
// The subprocess emits one JSON object per state change to stdout (diff
// mode), debounced 50ms. We consume it line-by-line via a NSFileHandle read
// observer and update the MediaRemoteInfo callback on the main queue.
//
// Lifecycle:
// - start() spawns the subprocess exactly once.
// - On SIGPIPE / stdout EOF / non-zero exit, we retry after a 2-second
// delay. After 3 consecutive crashes within 60s, we stop retrying and
// let NowPlayingState fall back to the legacy source chain.
// - stop() sends SIGTERM + waits up to 2s + SIGKILL if still alive.
//
// Credits: MediaRemoteAdapter.framework + mediaremote-adapter.pl
// Copyright (c) 2025 Jonas van den Berg. BSD-3-Clause.
// Bundled under Resources/mediaremote-adapter/ in this plugin.
//
import AppKit
import Foundation
// MARK: - Stream payload (subset of adapter output)
/// The track-level payload. Only the keys we consume are decoded;
/// adapter also emits `composer`, `contentItemIdentifier`,
/// `radioStationHash`, `timestamp` etc. which we ignore.
private struct AdapterStreamPayload: Decodable {
var title: String?
var artist: String?
var album: String?
var duration: Double?
var elapsedTime: Double?
var playbackRate: Double?
var playing: Bool?
var bundleIdentifier: String?
/// Base64-encoded artwork data. JSONDecoder decodes Data from base64
/// automatically via its default strategy.
var artworkData: Data?
}
/// Envelope that wraps every line emitted by `stream` mode. Structure is:
/// `{"type":"data","diff":<bool>,"payload":{...}}`. `diff: false` means
/// this is a full state snapshot (initial baseline OR after track change);
/// `diff: true` means only the changed fields are in payload. `get` mode
/// emits the payload directly without this envelope.
private struct AdapterStreamEnvelope: Decodable {
var type: String?
var diff: Bool?
var payload: AdapterStreamPayload?
}
// MARK: - Source
final class MediaRemoteAdapterSource {
// Configuration
private let scriptPath: String
private let frameworkPath: String
private let debounceMs: Int
// Callback to NowPlayingState
/// Called on the main queue whenever the subprocess emits a payload
/// that results in a usable MediaRemoteInfo. Called with nil when the
/// subprocess dies and restart is disabled.
var onUpdate: ((MediaRemoteInfo) -> Void)?
// Process state
private var process: Process?
private var stdoutHandle: FileHandle?
private var stderrHandle: FileHandle?
private var lineBuffer = Data()
// Aggregated "current state" adapter sends diffs, so we merge them
// ourselves. Apple Music frequently sends a playbackRate-only diff
// when the user pauses, so we need to remember title/artist from earlier.
private var currentInfo = MediaRemoteInfo()
// Crash / restart tracking
private var crashTimestamps: [Date] = []
private let maxCrashesPer60s = 3
private var restartWorkItem: DispatchWorkItem?
private var stopped = false
// MARK: - Init
/// Initialises the source with paths resolved from the plugin bundle.
/// Returns nil if either path is missing caller should fall back to
/// the legacy chain.
init?() {
// Resolve bundle that contains THIS source's compiled class. Using
// Bundle(for:) instead of Bundle.main because the plugin loads into
// the host's address space Bundle.main is the host, not us.
let bundle = Bundle(for: PathResolverToken.self)
guard let script = bundle.path(forResource: "mediaremote-adapter",
ofType: "pl",
inDirectory: "mediaremote-adapter")
?? bundle.path(forResource: "mediaremote-adapter", ofType: "pl")
else {
NSLog("[mio-plugin-music] adapter script not found in bundle")
return nil
}
let resourcesRoot = (script as NSString).deletingLastPathComponent
let framework = (resourcesRoot as NSString)
.appendingPathComponent("MediaRemoteAdapter.framework")
guard FileManager.default.fileExists(atPath: framework) else {
NSLog("[mio-plugin-music] adapter framework not found at \(framework)")
return nil
}
self.scriptPath = script
self.frameworkPath = framework
self.debounceMs = 50
}
deinit {
stop()
}
// MARK: - Lifecycle
func start() {
stopped = false
spawn()
}
func stop() {
stopped = true
restartWorkItem?.cancel()
restartWorkItem = nil
terminateProcess()
}
private func terminateProcess() {
guard let proc = process else { return }
process = nil
stdoutHandle?.readabilityHandler = nil
stdoutHandle = nil
stderrHandle?.readabilityHandler = nil
stderrHandle = nil
if proc.isRunning {
proc.terminate()
// Give it 500ms to exit cleanly, then force.
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
if proc.isRunning {
kill(proc.processIdentifier, SIGKILL)
}
}
}
}
// MARK: - Spawn
private func spawn() {
guard !stopped else { return }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [
scriptPath,
frameworkPath,
"stream",
"--debounce=\(debounceMs)"
]
// Minimize inherited env Perl / DynaLoader doesn't need our full
// shell environment. Keep PATH so Perl can find its own modules.
proc.environment = [
"PATH": "/usr/bin:/bin",
"LANG": "en_US.UTF-8"
]
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
proc.terminationHandler = { [weak self] p in
DispatchQueue.main.async { self?.handleTermination(status: p.terminationStatus) }
}
stdoutHandle = outPipe.fileHandleForReading
stderrHandle = errPipe.fileHandleForReading
stdoutHandle?.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
DispatchQueue.main.async { self?.ingestStdout(data) }
}
stderrHandle?.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let str = String(data: data, encoding: .utf8) {
NSLog("[mio-plugin-music] adapter stderr: \(str.trimmingCharacters(in: .whitespacesAndNewlines))")
}
_ = self
}
do {
try proc.run()
process = proc
debugLog("adapter spawned pid=\(proc.processIdentifier)")
// Bootstrap pull current state via one-shot `get`. Covers the
// case where the stream subprocess started BEFORE any music app
// was opened; in that case the initial stream emit is null/empty,
// and no diff comes until something changes. A parallel `get`
// catches whatever is playing right now.
//
// CRITICAL: runs on a BACKGROUND queue because bootstrapGet()
// calls Process.waitUntilExit() which blocks synchronously for
// 500ms-1s (Perl boot + framework load). Running that on main
// freezes the whole UI looked like a crash/hang on launch.
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.bootstrapGet()
}
} catch {
debugLog("adapter spawn failed: \(error)")
scheduleRestart()
}
}
/// Runs on a BACKGROUND queue. Parses JSON on bg, hops to main for
/// the merge + onUpdate so `currentInfo` is only ever mutated on the
/// main queue (same queue as stream's parseLine).
private func bootstrapGet() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [scriptPath, frameworkPath, "get"]
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
let outPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = FileHandle(forWritingAtPath: "/dev/null")
do {
try proc.run()
proc.waitUntilExit()
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
guard !data.isEmpty else {
debugLog("bootstrap get returned empty")
return
}
guard let payload = try? JSONDecoder().decode(AdapterStreamPayload.self, from: data) else {
debugLog("bootstrap get · parse failed")
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.merge(payload)
self.debugLog("bootstrap get · title=\(self.currentInfo.title) playing=\(self.currentInfo.isPlaying)")
if self.currentInfo.hasTrack {
self.onUpdate?(self.currentInfo)
}
}
} catch {
// debugLog runs main-ok from any queue, just logs.
debugLog("bootstrap get failed: \(error)")
}
}
// MARK: - Stdout ingestion
private func ingestStdout(_ chunk: Data) {
lineBuffer.append(chunk)
// Adapter emits newline-delimited JSON. Parse as many complete
// lines as the buffer currently holds.
while let nlRange = lineBuffer.firstRange(of: Data([0x0A])) {
let lineData = lineBuffer.prefix(upTo: nlRange.lowerBound)
lineBuffer.removeSubrange(0 ..< nlRange.upperBound)
guard !lineData.isEmpty else { continue }
parseLine(Data(lineData))
}
}
private func parseLine(_ data: Data) {
do {
let env = try JSONDecoder().decode(AdapterStreamEnvelope.self, from: data)
guard env.type == "data" else {
debugLog("non-data envelope: \(env.type ?? "nil")")
return
}
guard let payload = env.payload else { return }
// Full snapshot (diff=false) reset, then merge, so stale
// fields from the previous track don't leak. Diff (default
// or true) merge only the provided fields.
if env.diff == false {
currentInfo = MediaRemoteInfo()
}
merge(payload)
debugLog("stream rx · diff=\(env.diff ?? true) title=\(currentInfo.title) artist=\(currentInfo.artist) playing=\(currentInfo.isPlaying) hasTrack=\(currentInfo.hasTrack)")
if currentInfo.hasTrack {
onUpdate?(currentInfo)
}
} catch {
if let preview = String(data: data.prefix(80), encoding: .utf8),
!preview.hasPrefix("{") && !preview.hasPrefix("null") {
debugLog("unparseable line: \(preview)")
}
}
}
/// File-based 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. Writing a line-oriented log to /tmp is the one
/// channel that always works for post-mortem inspection.
private func debugLog(_ msg: String) {
let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(msg)\n"
let path = "/tmp/mio-plugin-music-debug.log"
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: path),
let h = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
try? h.seekToEnd()
try? h.write(contentsOf: data)
try? h.close()
} else {
try? data.write(to: URL(fileURLWithPath: path))
}
}
}
/// Merge an adapter diff into `currentInfo`. Only overwrite fields that
/// the payload explicitly provided leave the rest at their previous
/// value so a "just the elapsed time changed" diff doesn't erase title.
private func merge(_ payload: AdapterStreamPayload) {
if let title = payload.title { currentInfo.title = title }
if let artist = payload.artist { currentInfo.artist = artist }
if let album = payload.album { currentInfo.album = album }
if let duration = payload.duration { currentInfo.duration = duration }
if let elapsed = payload.elapsedTime { currentInfo.elapsedTime = elapsed }
if let rate = payload.playbackRate { currentInfo.playbackRate = rate }
if let playing = payload.playing {
currentInfo.isPlaying = playing
} else if let rate = payload.playbackRate {
// Some diffs only ship playbackRate; derive isPlaying.
currentInfo.isPlaying = rate > 0
}
if let bid = payload.bundleIdentifier { currentInfo.bundleIdentifier = bid }
if let art = payload.artworkData, !art.isEmpty {
currentInfo.artwork = NSImage(data: art)
}
}
// MARK: - Termination / restart
private func handleTermination(status: Int32) {
NSLog("[mio-plugin-music] adapter terminated (status=\(status))")
stdoutHandle?.readabilityHandler = nil
stdoutHandle = nil
stderrHandle?.readabilityHandler = nil
stderrHandle = nil
process = nil
currentInfo = MediaRemoteInfo()
lineBuffer.removeAll()
scheduleRestart()
}
private func scheduleRestart() {
guard !stopped else { return }
let now = Date()
crashTimestamps.append(now)
crashTimestamps.removeAll { now.timeIntervalSince($0) > 60 }
if crashTimestamps.count > maxCrashesPer60s {
NSLog("[mio-plugin-music] adapter crashed \(crashTimestamps.count) times in 60s — giving up")
return
}
// Exponential-ish backoff: 1s, 2s, 4s by crash count within the window.
let delay = min(4.0, pow(2.0, Double(crashTimestamps.count - 1)))
let work = DispatchWorkItem { [weak self] in self?.spawn() }
restartWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
// MARK: - Transport (fire-and-forget short-lived subprocess)
/// Send a MediaRemote command ID. Uses a short-lived subprocess
/// rather than a persistent control channel keeps the architecture
/// simple and matches how Atoll does it.
/// Known commands (MRCommand IDs per adapter Perl examples):
/// 0=play, 1=pause, 2=togglePlayPause, 3=stop, 4=next, 5=previous
func sendCommand(_ id: Int) {
runOneShot(["send", String(id)])
}
/// Seek to position in seconds. Adapter takes microseconds, so *1e6.
func seek(_ seconds: Double) {
let micros = Int64(max(0, seconds) * 1_000_000)
runOneShot(["seek", String(micros)])
}
private func runOneShot(_ args: [String]) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [scriptPath, frameworkPath] + args
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
let devnull = FileHandle(forWritingAtPath: "/dev/null")
proc.standardOutput = devnull
proc.standardError = devnull
do {
try proc.run()
} catch {
NSLog("[mio-plugin-music] adapter one-shot failed: \(error)")
}
}
}
// Dummy class used only as a `Bundle(for:)` anchor so we can find our own
// plugin bundle without relying on Bundle.main (which is the host app).
private final class PathResolverToken {}

View File

@ -28,16 +28,30 @@ struct AppleScriptTrackInfo {
}
enum SpotifyAppleScript {
private static let bundleId = "com.spotify.client"
static let bundleId = "com.spotify.client"
private static let sourceName = "Spotify"
/// Fast check: is Spotify actually running? When false, skip AppleScript
/// entirely the 2s `with timeout` would still trip but that's two
/// wasted seconds per router pass for an app the user isn't using.
static var isRunning: Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == bundleId
}
}
// MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? {
// `with timeout of 2 seconds` bounds the tell block; if Spotify hangs,
// AppleScript raises errAETimeout (-1712) and our Swift layer returns
// nil so the source router can move on instead of the serial queue
// stalling for the default 120-second AppleEvent timeout.
let script = """
tell application "System Events"
if not (exists process "Spotify") then return "NOT_RUNNING"
end tell
with timeout of 2 seconds
tell application "Spotify"
if player state is playing or player state is paused then
set trackName to name of current track
@ -56,6 +70,7 @@ enum SpotifyAppleScript {
return "NOT_PLAYING"
end if
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "spotify") else { return nil }
@ -87,6 +102,7 @@ enum SpotifyAppleScript {
tell application "System Events"
if not (exists process "Spotify") then return ""
end tell
with timeout of 2 seconds
tell application "Spotify"
try
return artwork url of current track
@ -94,6 +110,7 @@ enum SpotifyAppleScript {
return ""
end try
end tell
end timeout
"""
guard let urlString = await runAppleScript(script, tag: "spotify-art"),
!urlString.isEmpty,

View File

@ -115,4 +115,16 @@ enum L10n {
static var unknownArtist: String {
isChinese ? "未知艺术家" : "Unknown Artist"
}
static var floatLyricsTooltip: String {
isChinese ? "悬浮歌词窗 · 点击切换显示" : "Floating lyrics window · toggle visibility"
}
static var lyricsPlaceholder: String {
isChinese ? "歌词暂未接入 · 等待真实数据源" : "Lyrics not wired yet — placeholder"
}
static var lyricsStyleLabel: String {
isChinese ? "样式" : "Style"
}
}

View File

@ -0,0 +1,369 @@
//
// DesktopLyricsViews.swift
// MioIsland Music Plugin
//
// The three floating lyrics window variants. Shared traits:
// - NSVisualEffectView-backed blur via .background(.ultraThinMaterial)
// when available, fallback to semi-transparent color.
// - Draggable via the window's isMovableByWindowBackground (no extra
// gesture recognisers needed).
// - All text / progress / controls bound to NowPlayingState.shared.
// - Lyric lines are PLACEHOLDER text until we wire a real lyrics
// source. Only the `lyricLine(_:)` computed below changes when
// lyrics data becomes available.
//
import AppKit
import SwiftUI
// MARK: - Shared lyric slot helpers
private enum LyricSlot {
case previous
case current
case next
}
/// Pick the right synced-lyric line for a given slot. Falls back to a
/// sensible text when no lyrics are loaded so the window stays readable:
/// - previous / next "······" (tastefully blank)
/// - current track title on cold start, or
/// L10n.lyricsPlaceholder when paused / not-found
@MainActor
private func lyricText(_ slot: LyricSlot, state: NowPlayingState) -> String {
let lines = state.syncedLyrics
let idx = state.currentLyricIndex
if !lines.isEmpty {
switch slot {
case .previous:
let i = idx - 1
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
case .current:
if idx >= 0 && idx < lines.count { return lines[idx].text }
// Before first lyric line (elapsedTime < first timestamp).
return lines.first?.text ?? (state.title.isEmpty ? L10n.lyricsPlaceholder : state.title)
case .next:
let i = idx + 1
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
}
}
// No lyrics loaded / not found graceful fallback.
switch slot {
case .previous: return "······"
case .current:
return state.isPlaying
? (state.title.isEmpty ? L10n.unknownTitle : state.title)
: L10n.lyricsPlaceholder
case .next:
return state.artist.isEmpty ? "······" : "\(state.artist)"
}
}
// MARK: - Shared SVG-equivalent transport controls
private struct MiniControls: View {
@ObservedObject var state: NowPlayingState = .shared
let playButtonSize: CGFloat
let iconButtonSize: CGFloat
let filledPlay: Bool // Bar/Karaoke use filled white; Cinema similar
var body: some View {
HStack(spacing: 4) {
button(icon: "backward.fill", size: iconButtonSize) {
state.previousTrack()
}
Button(action: { state.togglePlayPause() }) {
ZStack {
Circle()
.fill(filledPlay ? Color.white.opacity(0.95) : Color.white.opacity(0.9))
.frame(width: playButtonSize, height: playButtonSize)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: playButtonSize * 0.42, weight: .bold))
.foregroundColor(.black)
.offset(x: state.isPlaying ? 0 : 1)
}
}
.buttonStyle(.plain)
button(icon: "forward.fill", size: iconButtonSize) {
state.nextTrack()
}
}
}
@ViewBuilder
private func button(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: size * 0.48, weight: .semibold))
.foregroundColor(.white.opacity(0.7))
.frame(width: size, height: size)
.background(Circle().fill(Color.white.opacity(0.001)))
.contentShape(Circle())
}
.buttonStyle(.plain)
}
}
private let floatBackground = Color(red: 0x12/255, green: 0x10/255, blue: 0x16/255).opacity(0.62)
private let floatStroke = Color.white.opacity(0.12)
/// ViewModifier applying the shared glass chrome (blur + border + shadow).
private struct FloatChrome: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
content
.background(
ZStack {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.ultraThinMaterial)
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(floatBackground)
}
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(floatStroke, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.shadow(color: .black.opacity(0.5), radius: 30, y: 8)
.shadow(color: .black.opacity(0.4), radius: 8, y: 2)
}
}
// MARK: - Rotating vinyl disc (for Bar + Cinema)
private struct VinylDisc: View {
let artwork: NSImage?
let isPlaying: Bool
let diameter: CGFloat
/// TimelineView drives rotation off the monotonic wall clock, which is
/// immune to SwiftUI re-creating the view (window hide/show, style
/// switch). `withAnimation(.repeatForever)` used to lose the animation
/// on re-creation and snap to rest. Wall-clock-based rotation just
/// always looks right derive angle from `elapsed % 8s * 45°/s`.
@State private var pauseAccumulator: Double = 0
@State private var pauseStart: Date? = nil
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isPlaying)) { ctx in
let elapsed = ctx.date.timeIntervalSinceReferenceDate
// 8-second period 45°/s. Multiplying by 45 and wrapping to
// [0, 360) keeps the rotation smooth across many hours without
// floating-point drift.
let angle = (elapsed * 45.0).truncatingRemainder(dividingBy: 360)
disc.rotationEffect(.degrees(angle))
}
}
private var disc: some View {
ZStack {
Circle().fill(Color.black)
if let art = artwork {
Image(nsImage: art)
.resizable()
.scaledToFill()
.frame(width: diameter * 0.55, height: diameter * 0.55)
.clipShape(Circle())
} else {
Circle()
.fill(LinearGradient(
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
Color(red: 0.27, green: 0.35, blue: 0.33)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.frame(width: diameter * 0.55, height: diameter * 0.55)
}
Circle()
.fill(Color.black)
.frame(width: diameter * 0.1, height: diameter * 0.1)
}
.frame(width: diameter, height: diameter)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.15), lineWidth: 0.5)
)
}
}
// MARK: - Model 1 · Bar
struct LyricsBarView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
HStack(spacing: 16) {
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 36)
Text(lyricText(.current, state: state))
.font(.system(size: 20, weight: .medium))
.tracking(-0.1)
.foregroundColor(.white.opacity(0.95))
.lineLimit(1)
.shadow(color: .black.opacity(0.4), radius: 6, y: 2)
.frame(maxWidth: .infinity, alignment: .leading)
MiniControls(state: state, playButtonSize: 28, iconButtonSize: 24, filledPlay: true)
}
.padding(.horizontal, 22)
.padding(.vertical, 14)
.modifier(FloatChrome(radius: 999))
.padding(4) // breathing room so shadow isn't clipped by window
}
}
// MARK: - Model 2 · Karaoke
struct LyricsKaraokeView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 10) {
Circle()
.fill(Color(red: 0.9, green: 0.72, blue: 0.53))
.frame(width: 5, height: 5)
Text(state.sourceName.isEmpty
? (L10n.isChinese ? "歌词同步" : "Lyrics Sync")
: "\(L10n.isChinese ? "歌词同步 · " : "Lyrics · ")\(state.sourceName)")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.55))
Text("·")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.3))
Text("\(state.formattedElapsed) / \(state.formattedDuration)")
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(.white.opacity(0.55))
Spacer()
Text("⋮⋮ drag")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(.white.opacity(0.3))
}
.padding(.bottom, 12)
// Current (big)
Text(lyricText(.current, state: state))
.font(.system(size: 26, weight: .semibold))
.tracking(-0.3)
.foregroundColor(.white)
.lineLimit(1)
.shadow(color: .black.opacity(0.35), radius: 10, y: 3)
// Next (faint)
Text(lyricText(.next, state: state))
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white.opacity(0.42))
.lineLimit(1)
.padding(.top, 6)
Divider()
.background(Color.white.opacity(0.08))
.padding(.vertical, 14)
// Meta + controls row
HStack(alignment: .center, spacing: 10) {
albumArtSmall
VStack(alignment: .leading, spacing: 1) {
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.85))
.lineLimit(1)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 10.5))
.foregroundColor(.white.opacity(0.45))
.lineLimit(1)
}
Spacer()
MiniControls(state: state, playButtonSize: 32, iconButtonSize: 28, filledPlay: true)
}
}
.padding(EdgeInsets(top: 22, leading: 26, bottom: 18, trailing: 26))
.modifier(FloatChrome(radius: 20))
.padding(4)
}
@ViewBuilder
private var albumArtSmall: some View {
if let art = state.albumArt {
Image(nsImage: art)
.resizable()
.scaledToFill()
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(LinearGradient(
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
Color(red: 0.27, green: 0.35, blue: 0.33)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.frame(width: 28, height: 28)
}
}
}
// MARK: - Model 3 · Cinema
struct LyricsCinemaView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
VStack(spacing: 0) {
// Prev line (faint)
Text(lyricText(.previous, state: state))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(0.3))
.lineLimit(1)
.padding(.bottom, 12)
// Now line (huge)
Text(lyricText(.current, state: state))
.font(.system(size: 34, weight: .bold))
.tracking(-0.6)
.foregroundColor(.white)
.lineLimit(1)
.shadow(color: .black.opacity(0.4), radius: 16, y: 3)
// Next line (faint)
Text(lyricText(.next, state: state))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(0.4))
.lineLimit(1)
.padding(.top, 12)
Divider()
.background(Color.white.opacity(0.08))
.padding(.top, 28)
.padding(.bottom, 18)
// Footer
HStack(spacing: 10) {
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 22)
Text("\(state.title.isEmpty ? L10n.unknownTitle : state.title) · \(state.artist.isEmpty ? L10n.unknownArtist : state.artist)")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.55))
.lineLimit(1)
Spacer()
MiniControls(state: state, playButtonSize: 30, iconButtonSize: 30, filledPlay: true)
}
}
.padding(EdgeInsets(top: 40, leading: 40, bottom: 28, trailing: 40))
.background(
// Faint color wash for cinema feel
LinearGradient(
colors: [
Color(red: 0.9, green: 0.72, blue: 0.53).opacity(0.10),
Color.clear
],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.modifier(FloatChrome(radius: 24))
.padding(4)
}
}

View File

@ -0,0 +1,243 @@
//
// DesktopLyricsWindow.swift
// MioIsland Music Plugin
//
// Floating desktop "lyrics" overlay window always-on-top, movable by
// dragging anywhere on its background, dismissable with Escape. Three
// style variants the user can cycle through:
//
// Bar (Model 1) narrow single-line pill, 520×64
// Karaoke (Model 2) two-line card, current + next, 560×170
// Cinema (Model 3) 3-line large typography, 640×260
//
// All variants derive their title/artist/progress/isPlaying from
// NowPlayingState.shared. Lyrics data is NOT yet piped in (MediaRemote
// adapter doesn't expose lyric timings and there's no public API on
// Apple Music / Spotify), so the "lyric line" slot shows a placeholder
// string. When a lyric source lands, only the `currentLine` /
// `nextLine` / `prevLine` computed properties need to change.
//
// Window is one per app `DesktopLyricsWindow.shared` serves toggles
// from the ExpandedView's pin button.
//
import AppKit
import SwiftUI
import Combine
// MARK: - Style enum
enum LyricsStyle: String, CaseIterable, Identifiable {
case bar
case karaoke
case cinema
var id: String { rawValue }
var windowSize: CGSize {
switch self {
case .bar: return CGSize(width: 520, height: 64)
case .karaoke: return CGSize(width: 560, height: 170)
case .cinema: return CGSize(width: 640, height: 260)
}
}
var displayName: String {
switch self {
case .bar: return L10n.isChinese ? "单行胶囊" : "Bar"
case .karaoke: return L10n.isChinese ? "双行卡拉" : "Karaoke"
case .cinema: return L10n.isChinese ? "影院大字" : "Cinema"
}
}
}
// MARK: - Window
@MainActor
final class DesktopLyricsWindow {
static let shared = DesktopLyricsWindow()
private var window: NSWindow?
private let stylePrefsKey = "mio.music.lyricsStyle.v1"
private init() {}
var isVisible: Bool {
window?.isVisible ?? false
}
func toggle() {
if isVisible {
hide()
} else {
show()
}
}
func show() {
if let existing = window {
existing.orderFront(nil)
return
}
let style = loadStyle()
let root = DesktopLyricsRootView(initialStyle: style) { [weak self] newStyle in
self?.saveStyle(newStyle)
self?.resizeTo(newStyle.windowSize)
}
let host = NSHostingView(rootView: root)
let win = DraggableBorderlessWindow(
contentRect: NSRect(origin: .zero, size: style.windowSize),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
win.contentView = host
win.backgroundColor = .clear
win.isOpaque = false
win.hasShadow = true
win.isMovableByWindowBackground = true
win.level = .floating // always-on-top
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.ignoresMouseEvents = false // we need clicks for controls
win.isReleasedWhenClosed = false
// Default placement bottom center of the primary screen, 80pt
// above the Dock. User can drag from there.
if let screen = NSScreen.main {
let f = screen.visibleFrame
let x = f.midX - style.windowSize.width / 2
let y = f.minY + 80
win.setFrameOrigin(NSPoint(x: x, y: y))
}
win.orderFront(nil)
window = win
}
func hide() {
window?.orderOut(nil)
}
func close() {
window?.close()
window = nil
}
private func resizeTo(_ size: CGSize) {
guard let win = window else { return }
var frame = win.frame
frame.origin.y += (frame.size.height - size.height) // anchor to bottom edge
frame.size = size
win.setFrame(frame, display: true, animate: true)
}
private func loadStyle() -> LyricsStyle {
if let raw = UserDefaults.standard.string(forKey: stylePrefsKey),
let s = LyricsStyle(rawValue: raw) {
return s
}
return .bar
}
private func saveStyle(_ style: LyricsStyle) {
UserDefaults.standard.set(style.rawValue, forKey: stylePrefsKey)
}
}
// Borderless NSWindows can become key (so Escape works) and swallow the
// mouse events on our control buttons while still letting drag-background
// move the window.
private final class DraggableBorderlessWindow: NSWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func keyDown(with event: NSEvent) {
// Escape hide (consistent with other floating overlays).
if event.keyCode == 53 {
DesktopLyricsWindow.shared.hide()
return
}
super.keyDown(with: event)
}
}
// MARK: - Root view
/// Hosts the style picker + the currently selected variant. State-changing
/// props go up to the window via the `onStyleChange` callback so the
/// window can resize.
private struct DesktopLyricsRootView: View {
@ObservedObject private var state = NowPlayingState.shared
@State private var style: LyricsStyle
let onStyleChange: (LyricsStyle) -> Void
init(initialStyle: LyricsStyle, onStyleChange: @escaping (LyricsStyle) -> Void) {
_style = State(initialValue: initialStyle)
self.onStyleChange = onStyleChange
}
var body: some View {
ZStack(alignment: .topTrailing) {
Group {
switch style {
case .bar: LyricsBarView()
case .karaoke: LyricsKaraokeView()
case .cinema: LyricsCinemaView()
}
}
.transition(.opacity)
// Tiny style-cycle chip in the very corner minimal, only
// visible on hover to stay out of the way.
StyleCyclerChip(current: style) { next in
withAnimation(.easeInOut(duration: 0.2)) {
style = next
}
onStyleChange(next)
}
.padding(8)
}
.frame(
width: style.windowSize.width,
height: style.windowSize.height
)
}
}
// MARK: - Cycle chip
private struct StyleCyclerChip: View {
let current: LyricsStyle
let onChange: (LyricsStyle) -> Void
@State private var isHovered = false
var body: some View {
Button {
let all = LyricsStyle.allCases
let idx = all.firstIndex(of: current) ?? 0
onChange(all[(idx + 1) % all.count])
} label: {
HStack(spacing: 5) {
Image(systemName: "rectangle.3.offgrid")
.font(.system(size: 9, weight: .semibold))
Text(current.displayName)
.font(.system(size: 9, weight: .medium, design: .monospaced))
}
.foregroundColor(.white.opacity(isHovered ? 0.9 : 0.5))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.black.opacity(isHovered ? 0.45 : 0.25))
)
.overlay(
Capsule().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.help(L10n.lyricsStyleLabel)
}
}

View File

@ -9,13 +9,18 @@
// 3. Nothing playing (title.isEmpty)
// 4. Now playing (default)
//
// Background uses an extracted tint from the album art (fades to
// near-black). Control surface, text and spacing follow the
// MioIsland aesthetic:
// - #0A0A0A near-black base
// - white text with opacity tiers (1.0 / 0.7 / 0.5 / 0.3)
// - lime #CAFF00 as the single accent color
// - 16pt corner on the big card, 8pt on small chips
// v2.0.1 layout: compact horizontal hero inspired by SuperIsland's
// NowPlaying. Medium album art on the left, metadata + source badge
// on the right, progress + times inline below, transport controls
// at bottom. Half the vertical footprint of v2.0.0 for the same
// information density.
//
// Background uses a tint extracted from the album art (fades into
// a near-black base). Palette:
// #0A0A0A near-black base
// white text tiers 1.0 / 0.75 / 0.45 / 0.3
// lime #CAFF00 as the single accent color
// 16pt corner on the big art, 8pt on small chips
//
import AppKit
@ -39,14 +44,28 @@ struct ExpandedView: View {
// MARK: - Body
var body: some View {
ZStack {
AlbumArtColorExtractor
.backgroundGradient(for: tintColor)
ZStack(alignment: .center) {
// V2 Immersive backdrop applied only when actually playing,
// other modes (empty / warning) use the plain near-black base.
if currentMode == .playing {
immersiveBackdrop
.ignoresSafeArea()
}
content
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(20)
// Top-right float-window toggle only when playing.
if currentMode == .playing {
VStack {
HStack {
Spacer()
floatWindowToggle
}
Spacer()
}
.padding(14)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Self.base)
@ -57,6 +76,57 @@ struct ExpandedView: View {
.animation(.easeInOut(duration: 0.25), value: currentMode)
}
/// V2 Immersive backdrop: blurred enlarged album art + dark gradient
/// overlay (35% 72% 92%). Falls back to a solid base when no art.
@ViewBuilder
private var immersiveBackdrop: some View {
if let art = state.albumArt {
ZStack {
Image(nsImage: art)
.resizable()
.scaledToFill()
.saturation(1.4)
.blur(radius: 40, opaque: true)
.scaleEffect(1.3)
.clipped()
LinearGradient(
gradient: Gradient(stops: [
.init(color: Self.base.opacity(0.35), location: 0.0),
.init(color: Self.base.opacity(0.72), location: 0.55),
.init(color: Self.base.opacity(0.92), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
}
} else {
AlbumArtColorExtractor.backgroundGradient(for: tintColor)
}
}
/// Pin/float button that toggles the desktop lyrics overlay.
/// Icon is static (`pip.enter`) we don't observe the window to keep
/// this view free of a @StateObject dependency. The window itself is
/// the visibility signal.
private var floatWindowToggle: some View {
Button {
DesktopLyricsWindow.shared.toggle()
} label: {
Image(systemName: "pip.enter")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.85))
.frame(width: 28, height: 28)
.background(
Circle().fill(Color.black.opacity(0.35))
)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.help(L10n.floatLyricsTooltip)
}
// MARK: - State routing
private enum Mode: Equatable {
@ -99,74 +169,60 @@ struct ExpandedView: View {
}
}
// MARK: - Playing card
// MARK: - Playing card compact horizontal layout
private var playingCard: some View {
VStack(spacing: 0) {
// Header row: small eyebrow + source badge.
HStack(alignment: .firstTextBaseline) {
Text(L10n.nowPlayingHeading.uppercased())
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundColor(Self.ink.opacity(0.5))
Spacer()
sourceBadge
}
.padding(.bottom, 22)
// Album art (big, centered)
// V2 Immersive layout centered column: cover 120 title artist
// progress controls with outline play button. Matches the
// Claude Design CodeIsland Music.html V2 spec.
VStack(spacing: 14) {
// Large centered album art shadow drops onto blurred backdrop
albumArt
.padding(.bottom, 24)
.frame(width: 120, height: 120)
// Title + artist + album
VStack(spacing: 8) {
VStack(spacing: 4) {
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 22, weight: .semibold))
.font(.system(size: 19, weight: .semibold))
.tracking(-0.35)
.foregroundColor(Self.ink)
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(1)
.shadow(color: .black.opacity(0.35), radius: 6, y: 2)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 14, weight: .regular))
.foregroundColor(Self.ink.opacity(0.75))
.lineLimit(1)
if !state.album.isEmpty {
Text(state.album)
.font(.system(size: 12, weight: .regular))
.foregroundColor(Self.ink.opacity(0.45))
.font(.system(size: 13))
.foregroundColor(Self.ink.opacity(0.78))
.lineLimit(1)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 28)
.frame(maxWidth: 360)
// Seek bar + time labels
VStack(spacing: 6) {
// Source chip (below artist, subtle)
sourceBadge
// Progress bar + times
VStack(spacing: 8) {
SeekBar(
progress: state.progress,
duration: state.duration
) { newTime in
state.seek(to: newTime)
}
HStack {
Text(state.formattedElapsed)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.5))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55))
Spacer()
Text(state.formattedDuration)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.5))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55))
}
}
.padding(.bottom, 24)
.padding(.top, 6)
Spacer(minLength: 0)
// Transport controls
transportControls
}
.frame(maxWidth: 520)
.frame(maxWidth: 380)
}
private var albumArt: some View {
@ -175,33 +231,31 @@ struct ExpandedView: View {
Image(nsImage: art)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 260, height: 260)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white.opacity(0.08))
.frame(width: 260, height: 260)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 64, weight: .light))
.font(.system(size: 34, weight: .light))
.foregroundColor(Self.ink.opacity(0.35))
)
}
}
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
.shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10)
}
private var sourceBadge: some View {
HStack(spacing: 6) {
HStack(spacing: 5) {
Circle()
.fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4))
.frame(width: 6, height: 6)
.frame(width: 5, height: 5)
Text(displaySourceName)
.font(.system(size: 10, weight: .medium))
.foregroundColor(Self.ink.opacity(0.7))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(
Capsule().fill(Color.white.opacity(0.08))
)
@ -212,25 +266,28 @@ struct ExpandedView: View {
}
private var transportControls: some View {
HStack(spacing: 40) {
HStack(spacing: 28) {
transportButton(
symbol: "backward.fill",
size: 20,
size: 16,
tooltip: L10n.previousTooltip
) {
state.previousTrack()
}
// Play / pause. Larger, accent button.
// Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56)
// V2 Immersive: outline play button (1.5px 85% white) lets the
// blurred album backdrop breathe through instead of punching a
// big lime disc that fights the art.
Button(action: { state.togglePlayPause() }) {
ZStack {
Circle()
.fill(Self.lime)
.frame(width: 56, height: 56)
.strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5)
.frame(width: 48, height: 48)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.black)
.offset(x: state.isPlaying ? 0 : 2) // optical nudge for play
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Self.ink)
.offset(x: state.isPlaying ? 0 : 2)
}
}
.buttonStyle(.plain)
@ -239,7 +296,7 @@ struct ExpandedView: View {
transportButton(
symbol: "forward.fill",
size: 20,
size: 16,
tooltip: L10n.nextTooltip
) {
state.nextTrack()
@ -264,23 +321,23 @@ struct ExpandedView: View {
// MARK: - Empty card (nothing playing)
private var emptyCard: some View {
VStack(spacing: 14) {
VStack(spacing: 12) {
Image(systemName: "music.note")
.font(.system(size: 44, weight: .light))
.font(.system(size: 40, weight: .light))
.foregroundColor(Self.ink.opacity(0.3))
Text(L10n.nothingPlaying)
.font(.system(size: 18, weight: .semibold))
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.7))
Text(L10n.nothingPlayingHint)
.font(.system(size: 12, weight: .regular))
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.4))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(32)
.frame(maxWidth: 360)
.padding(24)
.frame(maxWidth: 320)
}
// MARK: - Warning cards (host outdated / chinese app detected)
@ -291,31 +348,31 @@ struct ExpandedView: View {
hint: String,
tint: Color
) -> some View {
VStack(spacing: 14) {
VStack(spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 40, weight: .regular))
.font(.system(size: 36, weight: .regular))
.foregroundColor(tint)
Text(title)
.font(.system(size: 16, weight: .semibold))
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.9))
.multilineTextAlignment(.center)
Text(hint)
.font(.system(size: 12, weight: .regular))
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.55))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(2)
}
.padding(28)
.frame(maxWidth: 380)
.padding(22)
.frame(maxWidth: 340)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.white.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
@ -359,7 +416,7 @@ private struct TransportIconButton: View {
Image(systemName: symbol)
.font(.system(size: size, weight: .semibold))
.foregroundColor(isHovered ? Self.lime : Color.white.opacity(0.75))
.frame(width: 44, height: 44)
.frame(width: 36, height: 36)
.background(
Circle()
.fill(Color.white.opacity(isHovered ? 0.10 : 0.0))

View File

@ -29,8 +29,24 @@ swiftc \
# Copy Info.plist
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
# Ad-hoc sign
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
# Bundle the MediaRemoteAdapter subprocess payload (Atoll-style).
# Resources/ contains `MediaRemoteAdapter.framework` + `mediaremote-adapter.pl`.
# Both are BSD-3-Clause by Jonas van den Berg (see LICENSE-THIRD-PARTY.md).
# We copy Resources/* into Contents/Resources so MediaRemoteAdapterSource
# can find them via Bundle(for:).path(forResource:ofType:).
if [ -d "Resources" ]; then
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
# Preserve framework executable bit (cp -R should, but be defensive)
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter" 2>/dev/null || true
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/mediaremote-adapter.pl"
fi
# Ad-hoc sign the WHOLE bundle including the nested framework. Passing
# --deep traverses nested code signatures and re-signs them with our
# ad-hoc identity so the framework loads without Gatekeeper complaints
# when the plugin is dropped into ~/.config/codeisland/plugins/.
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"