Compare commits

..

No commits in common. "main" and "v2.0.1" have entirely different histories.
main ... v2.0.1

21 changed files with 80 additions and 2374 deletions

View File

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

View File

@ -1,46 +0,0 @@
# 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

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

View File

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

View File

@ -1,28 +0,0 @@
<?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

@ -1,128 +0,0 @@
<?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

@ -1,257 +0,0 @@
#!/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 id: String { "music-player" }
var name: String { "Music Player" } var name: String { "Music Player" }
var icon: String { "music.note" } var icon: String { "music.note" }
var version: String { "2.0.3" } var version: String { "2.0.1" }
func activate() { func activate() {
NSLog("[mio-plugin-music] activate") NSLog("[mio-plugin-music] activate")

View File

@ -31,10 +31,6 @@ import Combine
enum NowPlayingSourceKind: String { enum NowPlayingSourceKind: String {
case none 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 mediaRemote
case spotify case spotify
case appleMusic case appleMusic
@ -75,15 +71,6 @@ final class NowPlayingState: ObservableObject {
/// 使" hint. /// 使" hint.
@Published var chineseAppDetected: String? @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 // MARK: - Derived
var progress: Double { var progress: Double {
@ -105,11 +92,6 @@ final class NowPlayingState: ObservableObject {
// MARK: - Private // MARK: - Private
private let mediaRemote = MediaRemoteSource() 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 pollTimer: Timer?
private var playbackTimer: Timer? private var playbackTimer: Timer?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -118,20 +100,6 @@ final class NowPlayingState: ObservableObject {
private var isRunning = false private var isRunning = false
private var refreshInFlight = 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() {} private init() {}
// MARK: - Lifecycle // MARK: - Lifecycle
@ -148,17 +116,6 @@ final class NowPlayingState: ObservableObject {
Task { @MainActor in self?.refresh() } 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. // Observe Spotify distributed notifications for instant reaction.
DistributedNotificationCenter.default().addObserver( DistributedNotificationCenter.default().addObserver(
self, self,
@ -167,56 +124,13 @@ final class NowPlayingState: ObservableObject {
object: nil object: nil
) )
// Observe Apple Music. macOS 15+ Music.app emits // Observe Apple Music similarly.
// com.apple.Music.playerInfo; older iTunes emitted
// com.apple.iTunes.playerInfo. Register both so track changes are
// picked up instantly regardless of which one the current build
// broadcasts.
DistributedNotificationCenter.default().addObserver( DistributedNotificationCenter.default().addObserver(
self, self,
selector: #selector(musicStateChanged), selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.Music.playerInfo"), name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil object: nil
) )
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(musicStateChanged),
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
object: nil
)
// 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() startPolling()
refresh() refresh()
@ -232,14 +146,6 @@ final class NowPlayingState: ObservableObject {
playbackTimer?.invalidate() playbackTimer?.invalidate()
playbackTimer = nil playbackTimer = nil
DistributedNotificationCenter.default().removeObserver(self) DistributedNotificationCenter.default().removeObserver(self)
let wsCenter = NSWorkspace.shared.notificationCenter
for token in workspaceObservers {
wsCenter.removeObserver(token)
}
workspaceObservers.removeAll()
mediaRemoteAdapter?.stop()
} }
@objc private func spotifyStateChanged() { @objc private func spotifyStateChanged() {
@ -253,51 +159,8 @@ final class NowPlayingState: ObservableObject {
// MARK: - Polling // MARK: - Polling
private func startPolling() { 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?.invalidate()
currentPollInterval = newInterval pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refresh() } Task { @MainActor in self?.refresh() }
} }
} }
@ -321,193 +184,43 @@ final class NowPlayingState: ObservableObject {
} }
private func routeSources(allowAppleScript: Bool) async { private func routeSources(allowAppleScript: Bool) async {
// Adapter short-circuit: when the subprocess is the sticky source // Build the order: sticky source first, then the default chain.
// and we already have a track from it, there's nothing to do here let defaultOrder: [NowPlayingSourceKind] = [
// new data will arrive via `applyAdapterUpdate(_:)` whenever it .mediaRemote, .spotify, .appleMusic, .chrome
// actually changes. Polling on top of an event-driven source just ]
// wastes AppleScript round-trips. var order: [NowPlayingSourceKind] = []
if stickySource == .mediaRemoteAdapter, if stickySource != .none { order.append(stickySource) }
!title.isEmpty, for kind in defaultOrder where kind != stickySource {
mediaRemoteAdapter != nil { order.append(kind)
return
} }
// Running-app snapshot read once per pass so we don't hit the for kind in order {
// workspace API four times. // Skip AppleScript sources when the host cannot grant permission.
let spotifyRunning = SpotifyAppleScript.isRunning if !allowAppleScript, kind != .mediaRemote { continue }
let musicRunning = AppleMusicAppleScript.isRunning
let chromeRunning = ChromeWebSource.isRunning
// MediaRemote gate: on macOS 15.4+ the call returns an empty dict if let used = await tryFetch(kind) {
// 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 { await MainActor.run {
self.stickySource = used self.stickySource = used
self.updatePlaybackTimer() self.updatePlaybackTimer()
self.rearmPoll()
} }
return 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. // Nothing returned a hit; clear state.
await MainActor.run { await MainActor.run {
self.clearTrack() self.clearTrack()
self.stickySource = .none self.stickySource = .none
self.updatePlaybackTimer() 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. /// Try a single source. Returns the source kind on success, nil on miss.
private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? { private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
switch kind { switch kind {
case .none: case .none:
return nil return nil
case .mediaRemoteAdapter:
// Push-only source; pull-fetch is a no-op.
return nil
case .mediaRemote: case .mediaRemote:
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
Task { @MainActor in Task { @MainActor in
@ -529,12 +242,6 @@ final class NowPlayingState: ObservableObject {
case .appleMusic: case .appleMusic:
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil } guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
await MainActor.run { self.apply(appleScript: info) } await MainActor.run { self.apply(appleScript: info) }
// Apple Music doesn't expose an artwork URL via AppleScript;
// we dump the raw bytes to /tmp and reload. Only refetch when
// the track identity actually changes to avoid hammering disk.
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
await MainActor.run { self.albumArt = art }
}
return .appleMusic return .appleMusic
case .chrome: case .chrome:
@ -551,103 +258,6 @@ final class NowPlayingState: ObservableObject {
// MARK: - Apply // 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) { private func apply(mediaRemote info: MediaRemoteInfo) {
self.title = info.title self.title = info.title
self.artist = info.artist self.artist = info.artist
@ -662,11 +272,6 @@ final class NowPlayingState: ObservableObject {
} }
private func apply(appleScript info: AppleScriptTrackInfo) { private func apply(appleScript info: AppleScriptTrackInfo) {
// Track changed drop cached artwork so the source can refetch
// (Spotify does URL-based, Apple Music does raw-bytes-via-temp-file).
if self.title != info.title || self.artist != info.artist {
self.albumArt = nil
}
self.title = info.title self.title = info.title
self.artist = info.artist self.artist = info.artist
self.album = info.album self.album = info.album
@ -712,7 +317,6 @@ final class NowPlayingState: ObservableObject {
Task { @MainActor in Task { @MainActor in
guard let self, self.isPlaying else { return } guard let self, self.isPlaying else { return }
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration) self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
self.updateCurrentLyricIndex()
if self.elapsedTime >= self.duration { if self.elapsedTime >= self.duration {
self.playbackTimer?.invalidate() self.playbackTimer?.invalidate()
self.playbackTimer = nil self.playbackTimer = nil
@ -728,11 +332,8 @@ final class NowPlayingState: ObservableObject {
let shouldPlay = !isPlaying let shouldPlay = !isPlaying
isPlaying = shouldPlay isPlaying = shouldPlay
updatePlaybackTimer() updatePlaybackTimer()
rearmPoll() // isPlaying flipped maybe change poll cadence
switch stickySource { switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
case .spotify: case .spotify:
SpotifyAppleScript.togglePlay() SpotifyAppleScript.togglePlay()
case .appleMusic: case .appleMusic:
@ -745,13 +346,11 @@ final class NowPlayingState: ObservableObject {
} }
// Confirm from the real source after a short delay. // Confirm from the real source after a short delay.
scheduleRefresh(after: 0.1) scheduleRefresh(after: 0.3)
} }
func nextTrack() { func nextTrack() {
switch stickySource { switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
case .spotify: case .spotify:
SpotifyAppleScript.next() SpotifyAppleScript.next()
case .appleMusic: case .appleMusic:
@ -762,13 +361,11 @@ final class NowPlayingState: ObservableObject {
case .mediaRemote, .none: case .mediaRemote, .none:
mediaRemote.sendCommand(.nextTrack) mediaRemote.sendCommand(.nextTrack)
} }
scheduleRefresh(after: 0.1) scheduleRefresh(after: 0.3)
} }
func previousTrack() { func previousTrack() {
switch stickySource { switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
case .spotify: case .spotify:
SpotifyAppleScript.previous() SpotifyAppleScript.previous()
case .appleMusic: case .appleMusic:
@ -778,7 +375,7 @@ final class NowPlayingState: ObservableObject {
case .mediaRemote, .none: case .mediaRemote, .none:
mediaRemote.sendCommand(.previousTrack) mediaRemote.sendCommand(.previousTrack)
} }
scheduleRefresh(after: 0.1) scheduleRefresh(after: 0.3)
} }
func seek(to time: TimeInterval) { func seek(to time: TimeInterval) {
@ -787,8 +384,6 @@ final class NowPlayingState: ObservableObject {
updatePlaybackTimer() updatePlaybackTimer()
switch stickySource { switch stickySource {
case .mediaRemoteAdapter:
mediaRemoteAdapter?.seek(clamped)
case .spotify: case .spotify:
SpotifyAppleScript.seek(to: clamped) SpotifyAppleScript.seek(to: clamped)
case .appleMusic: case .appleMusic:
@ -800,7 +395,7 @@ final class NowPlayingState: ObservableObject {
mediaRemote.setElapsedTime(clamped) mediaRemote.setElapsedTime(clamped)
} }
scheduleRefresh(after: 0.1) scheduleRefresh(after: 0.3)
} }
private func scheduleRefresh(after delay: TimeInterval) { private func scheduleRefresh(after delay: TimeInterval) {

View File

@ -12,18 +12,9 @@
import AppKit import AppKit
enum AppleMusicAppleScript { enum AppleMusicAppleScript {
static let bundleId = "com.apple.Music" private static let bundleId = "com.apple.Music"
private static let sourceName = "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 // MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? { static func fetch() async -> AppleScriptTrackInfo? {
@ -74,45 +65,6 @@ enum AppleMusicAppleScript {
) )
} }
// MARK: - Artwork
/// Apple Music stores artwork as embedded raw data (PNG/JPEG) rather than
/// a URL. The cheapest way to pull it via AppleScript is to write the
/// bytes to a temp file and load NSImage from it. The script writes to
/// /tmp/mio-apple-music-art.dat (fixed path overwrites each call).
static func fetchArtwork() async -> NSImage? {
let tmpPath = "/tmp/mio-plugin-music-current-art.dat"
let script = """
tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING"
end tell
with timeout of 3 seconds
tell application "Music"
if player state is stopped then return "STOPPED"
try
set artData to data of artwork 1 of current track
set f to open for access POSIX file "\(tmpPath)" with write permission
set eof f to 0
write artData to f
close access f
return "OK"
on error errMsg
try
close access POSIX file "\(tmpPath)"
end try
return "NO_ARTWORK"
end try
end tell
end timeout
"""
guard let raw = await runAppleScript(script, tag: "music-art"),
raw == "OK" else {
return nil
}
let url = URL(fileURLWithPath: tmpPath)
return await Task.detached { NSImage(contentsOf: url) }.value
}
// MARK: - Controls // MARK: - Controls
static func togglePlay() { static func togglePlay() {

View File

@ -31,14 +31,6 @@ struct ChromeTrackInfo {
enum ChromeWebSource { enum ChromeWebSource {
static let bundleId = "com.google.Chrome" 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 // MARK: - Fetch
static func fetch() async -> ChromeTrackInfo? { static func fetch() async -> ChromeTrackInfo? {

View File

@ -1,242 +0,0 @@
//
// 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

@ -1,412 +0,0 @@
//
// 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,18 +28,9 @@ struct AppleScriptTrackInfo {
} }
enum SpotifyAppleScript { enum SpotifyAppleScript {
static let bundleId = "com.spotify.client" private static let bundleId = "com.spotify.client"
private static let sourceName = "Spotify" 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 // MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? { static func fetch() async -> AppleScriptTrackInfo? {

View File

@ -115,16 +115,4 @@ enum L10n {
static var unknownArtist: String { static var unknownArtist: String {
isChinese ? "未知艺术家" : "Unknown Artist" 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

@ -1,369 +0,0 @@
//
// 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

@ -1,243 +0,0 @@
//
// 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

@ -44,28 +44,14 @@ struct ExpandedView: View {
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
ZStack(alignment: .center) { ZStack {
// V2 Immersive backdrop applied only when actually playing, AlbumArtColorExtractor
// other modes (empty / warning) use the plain near-black base. .backgroundGradient(for: tintColor)
if currentMode == .playing {
immersiveBackdrop
.ignoresSafeArea() .ignoresSafeArea()
}
content content
.padding(20) .padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
// Top-right float-window toggle only when playing.
if currentMode == .playing {
VStack {
HStack {
Spacer()
floatWindowToggle
}
Spacer()
}
.padding(14)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Self.base) .background(Self.base)
@ -76,57 +62,6 @@ struct ExpandedView: View {
.animation(.easeInOut(duration: 0.25), value: currentMode) .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 // MARK: - State routing
private enum Mode: Equatable { private enum Mode: Equatable {
@ -172,57 +107,66 @@ struct ExpandedView: View {
// MARK: - Playing card compact horizontal layout // MARK: - Playing card compact horizontal layout
private var playingCard: some View { private var playingCard: some View {
// V2 Immersive layout centered column: cover 120 title artist VStack(spacing: 16) {
// progress controls with outline play button. Matches the // Hero row: album art left, metadata + source badge right
// Claude Design CodeIsland Music.html V2 spec. HStack(alignment: .top, spacing: 14) {
VStack(spacing: 14) {
// Large centered album art shadow drops onto blurred backdrop
albumArt albumArt
.frame(width: 120, height: 120)
VStack(spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Source badge, flush right with the artwork top
HStack {
Spacer()
sourceBadge
}
Text(state.title.isEmpty ? L10n.unknownTitle : state.title) Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 19, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.tracking(-0.35)
.foregroundColor(Self.ink) .foregroundColor(Self.ink)
.lineLimit(1) .lineLimit(2)
.shadow(color: .black.opacity(0.35), radius: 6, y: 2) .fixedSize(horizontal: false, vertical: true)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist) Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 13)) .font(.system(size: 13, weight: .regular))
.foregroundColor(Self.ink.opacity(0.78)) .foregroundColor(Self.ink.opacity(0.75))
.lineLimit(1)
if !state.album.isEmpty {
Text(state.album)
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.45))
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: 360)
// Source chip (below artist, subtle) Spacer(minLength: 0)
sourceBadge }
.frame(maxWidth: .infinity, alignment: .leading)
}
// Progress bar + times // Progress + times inline on one row
VStack(spacing: 8) { VStack(spacing: 6) {
SeekBar( SeekBar(
progress: state.progress, progress: state.progress,
duration: state.duration duration: state.duration
) { newTime in ) { newTime in
state.seek(to: newTime) state.seek(to: newTime)
} }
HStack { HStack {
Text(state.formattedElapsed) Text(state.formattedElapsed)
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55)) .foregroundColor(Self.ink.opacity(0.5))
Spacer() Spacer()
Text(state.formattedDuration) Text(state.formattedDuration)
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55)) .foregroundColor(Self.ink.opacity(0.5))
} }
} }
.padding(.top, 6)
Spacer(minLength: 0)
// Transport controls
transportControls transportControls
.padding(.top, 2)
} }
.frame(maxWidth: 380) .frame(maxWidth: 460)
} }
private var albumArt: some View { private var albumArt: some View {
@ -231,10 +175,12 @@ struct ExpandedView: View {
Image(nsImage: art) Image(nsImage: art)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} else { } else {
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.frame(width: 128, height: 128)
.overlay( .overlay(
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 34, weight: .light)) .font(.system(size: 34, weight: .light))
@ -242,7 +188,7 @@ struct ExpandedView: View {
) )
} }
} }
.shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10) .shadow(color: .black.opacity(0.35), radius: 14, x: 0, y: 6)
} }
private var sourceBadge: some View { private var sourceBadge: some View {
@ -276,18 +222,15 @@ struct ExpandedView: View {
} }
// Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56) // 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() }) { Button(action: { state.togglePlayPause() }) {
ZStack { ZStack {
Circle() Circle()
.strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5) .fill(Self.lime)
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill") Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .bold))
.foregroundColor(Self.ink) .foregroundColor(.black)
.offset(x: state.isPlaying ? 0 : 2) .offset(x: state.isPlaying ? 0 : 2) // optical nudge for play glyph
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@ -29,24 +29,8 @@ swiftc \
# Copy Info.plist # Copy Info.plist
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
# Bundle the MediaRemoteAdapter subprocess payload (Atoll-style). # Ad-hoc sign
# Resources/ contains `MediaRemoteAdapter.framework` + `mediaremote-adapter.pl`. codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
# 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}" echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"