mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
Compare commits
No commits in common. "main" and "v2.0.0" have entirely different histories.
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@ build/
|
||||
*.swiftmodule
|
||||
*.dSYM
|
||||
.build/
|
||||
icon/
|
||||
|
||||
15
Info.plist
15
Info.plist
@ -15,21 +15,10 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.2.2</string>
|
||||
<string>2.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>11</string>
|
||||
<string>2</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>
|
||||
|
||||
@ -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.
|
||||
@ -1 +0,0 @@
|
||||
Versions/Current/MediaRemoteAdapter
|
||||
@ -1 +0,0 @@
|
||||
Versions/Current/Resources
|
||||
Binary file not shown.
@ -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>
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
A
|
||||
@ -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: $@";
|
||||
}
|
||||
@ -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.3" }
|
||||
var version: String { "2.0.0" }
|
||||
|
||||
func activate() {
|
||||
NSLog("[mio-plugin-music] activate")
|
||||
|
||||
@ -31,10 +31,6 @@ 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
|
||||
@ -75,15 +71,6 @@ 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 {
|
||||
@ -105,11 +92,6 @@ 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>()
|
||||
@ -118,20 +100,6 @@ 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
|
||||
@ -148,17 +116,6 @@ 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,
|
||||
@ -167,56 +124,13 @@ final class NowPlayingState: ObservableObject {
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Observe Apple Music. macOS 15+ Music.app emits
|
||||
// com.apple.Music.playerInfo; older iTunes emitted
|
||||
// com.apple.iTunes.playerInfo. Register both so track changes are
|
||||
// picked up instantly regardless of which one the current build
|
||||
// broadcasts.
|
||||
// Observe Apple Music similarly.
|
||||
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()
|
||||
@ -232,14 +146,6 @@ 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() {
|
||||
@ -253,51 +159,8 @@ 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()
|
||||
currentPollInterval = newInterval
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
}
|
||||
}
|
||||
@ -321,193 +184,43 @@ final class NowPlayingState: ObservableObject {
|
||||
}
|
||||
|
||||
private func routeSources(allowAppleScript: Bool) async {
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
for kind in order {
|
||||
// Skip AppleScript sources when the host cannot grant permission.
|
||||
if !allowAppleScript, kind != .mediaRemote { continue }
|
||||
|
||||
// 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) {
|
||||
if let used = await tryFetch(kind) {
|
||||
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
|
||||
@ -529,12 +242,6 @@ 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:
|
||||
@ -551,103 +258,6 @@ 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 30–80.
|
||||
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
|
||||
@ -662,11 +272,6 @@ 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
|
||||
@ -712,7 +317,6 @@ 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
|
||||
@ -728,11 +332,8 @@ 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:
|
||||
@ -745,13 +346,11 @@ final class NowPlayingState: ObservableObject {
|
||||
}
|
||||
|
||||
// Confirm from the real source after a short delay.
|
||||
scheduleRefresh(after: 0.1)
|
||||
scheduleRefresh(after: 0.3)
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
|
||||
case .spotify:
|
||||
SpotifyAppleScript.next()
|
||||
case .appleMusic:
|
||||
@ -762,13 +361,11 @@ final class NowPlayingState: ObservableObject {
|
||||
case .mediaRemote, .none:
|
||||
mediaRemote.sendCommand(.nextTrack)
|
||||
}
|
||||
scheduleRefresh(after: 0.1)
|
||||
scheduleRefresh(after: 0.3)
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
|
||||
case .spotify:
|
||||
SpotifyAppleScript.previous()
|
||||
case .appleMusic:
|
||||
@ -778,7 +375,7 @@ final class NowPlayingState: ObservableObject {
|
||||
case .mediaRemote, .none:
|
||||
mediaRemote.sendCommand(.previousTrack)
|
||||
}
|
||||
scheduleRefresh(after: 0.1)
|
||||
scheduleRefresh(after: 0.3)
|
||||
}
|
||||
|
||||
func seek(to time: TimeInterval) {
|
||||
@ -787,8 +384,6 @@ final class NowPlayingState: ObservableObject {
|
||||
updatePlaybackTimer()
|
||||
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.seek(clamped)
|
||||
case .spotify:
|
||||
SpotifyAppleScript.seek(to: clamped)
|
||||
case .appleMusic:
|
||||
@ -800,7 +395,7 @@ final class NowPlayingState: ObservableObject {
|
||||
mediaRemote.setElapsedTime(clamped)
|
||||
}
|
||||
|
||||
scheduleRefresh(after: 0.1)
|
||||
scheduleRefresh(after: 0.3)
|
||||
}
|
||||
|
||||
private func scheduleRefresh(after delay: TimeInterval) {
|
||||
@ -837,11 +432,7 @@ func runAppleScript(_ source: String, tag: String) async -> String? {
|
||||
let result = script.executeAndReturnError(&errorDict)
|
||||
if let errorDict {
|
||||
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
|
||||
// Silence known-expected error codes:
|
||||
// -600 = application not running
|
||||
// -1712 = errAETimeout (our `with timeout of N seconds` firing)
|
||||
// -1728 = AEError, generic Apple Event descriptor issue
|
||||
if num != -600 && num != -1712 && num != -1728 {
|
||||
if num != -600 && num != -1728 {
|
||||
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
|
||||
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
|
||||
}
|
||||
|
||||
@ -12,47 +12,30 @@
|
||||
import AppKit
|
||||
|
||||
enum AppleMusicAppleScript {
|
||||
static let bundleId = "com.apple.Music"
|
||||
private 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
|
||||
set trackArtist to artist of current track
|
||||
set trackAlbum to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
set stateString to "PAUSED"
|
||||
if player state is playing then set stateString to "PLAYING"
|
||||
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition
|
||||
else
|
||||
return "NOT_PLAYING"
|
||||
end if
|
||||
end tell
|
||||
end timeout
|
||||
tell application "Music"
|
||||
if player state is playing or player state is paused then
|
||||
set trackName to name of current track
|
||||
set trackArtist to artist of current track
|
||||
set trackAlbum to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
set stateString to "PAUSED"
|
||||
if player state is playing then set stateString to "PLAYING"
|
||||
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition
|
||||
else
|
||||
return "NOT_PLAYING"
|
||||
end if
|
||||
end tell
|
||||
"""
|
||||
|
||||
guard let raw = await runAppleScript(script, tag: "music") else { return nil }
|
||||
@ -74,45 +57,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
|
||||
|
||||
static func togglePlay() {
|
||||
|
||||
@ -31,24 +31,13 @@ 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 ""
|
||||
@ -81,7 +70,6 @@ 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 }
|
||||
|
||||
@ -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:756–895). 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 }
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -28,49 +28,34 @@ struct AppleScriptTrackInfo {
|
||||
}
|
||||
|
||||
enum SpotifyAppleScript {
|
||||
static let bundleId = "com.spotify.client"
|
||||
private 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
|
||||
set trackArtist to artist of current track
|
||||
set trackAlbum to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
set stateString to "PAUSED"
|
||||
if player state is playing then set stateString to "PLAYING"
|
||||
set artURL to ""
|
||||
try
|
||||
set artURL to artwork url of current track
|
||||
end try
|
||||
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL
|
||||
else
|
||||
return "NOT_PLAYING"
|
||||
end if
|
||||
end tell
|
||||
end timeout
|
||||
tell application "Spotify"
|
||||
if player state is playing or player state is paused then
|
||||
set trackName to name of current track
|
||||
set trackArtist to artist of current track
|
||||
set trackAlbum to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
set stateString to "PAUSED"
|
||||
if player state is playing then set stateString to "PLAYING"
|
||||
set artURL to ""
|
||||
try
|
||||
set artURL to artwork url of current track
|
||||
end try
|
||||
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL
|
||||
else
|
||||
return "NOT_PLAYING"
|
||||
end if
|
||||
end tell
|
||||
"""
|
||||
|
||||
guard let raw = await runAppleScript(script, tag: "spotify") else { return nil }
|
||||
@ -102,15 +87,13 @@ 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
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
end tell
|
||||
end timeout
|
||||
tell application "Spotify"
|
||||
try
|
||||
return artwork url of current track
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
end tell
|
||||
"""
|
||||
guard let urlString = await runAppleScript(script, tag: "spotify-art"),
|
||||
!urlString.isEmpty,
|
||||
|
||||
@ -115,16 +115,4 @@ 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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -9,18 +9,13 @@
|
||||
// 3. Nothing playing (title.isEmpty)
|
||||
// 4. Now playing (default)
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
//
|
||||
|
||||
import AppKit
|
||||
@ -44,28 +39,14 @@ struct ExpandedView: View {
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
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()
|
||||
}
|
||||
ZStack {
|
||||
AlbumArtColorExtractor
|
||||
.backgroundGradient(for: tintColor)
|
||||
.ignoresSafeArea()
|
||||
|
||||
content
|
||||
.padding(20)
|
||||
|
||||
// Top-right float-window toggle only when playing.
|
||||
if currentMode == .playing {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
floatWindowToggle
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Self.base)
|
||||
@ -76,57 +57,6 @@ 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 {
|
||||
@ -169,60 +99,74 @@ struct ExpandedView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playing card — compact horizontal layout
|
||||
// MARK: - Playing card
|
||||
|
||||
private var playingCard: some View {
|
||||
// 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
|
||||
.frame(width: 120, height: 120)
|
||||
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)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
// Album art (big, centered)
|
||||
albumArt
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// Title + artist + album
|
||||
VStack(spacing: 8) {
|
||||
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.tracking(-0.35)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(Self.ink)
|
||||
.lineLimit(1)
|
||||
.shadow(color: .black.opacity(0.35), radius: 6, y: 2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Self.ink.opacity(0.78))
|
||||
.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))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// Source chip (below artist, subtle)
|
||||
sourceBadge
|
||||
|
||||
// Progress bar + times
|
||||
VStack(spacing: 8) {
|
||||
// Seek bar + time labels
|
||||
VStack(spacing: 6) {
|
||||
SeekBar(
|
||||
progress: state.progress,
|
||||
duration: state.duration
|
||||
) { newTime in
|
||||
state.seek(to: newTime)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(state.formattedElapsed)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(Self.ink.opacity(0.55))
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundColor(Self.ink.opacity(0.5))
|
||||
Spacer()
|
||||
Text(state.formattedDuration)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(Self.ink.opacity(0.55))
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundColor(Self.ink.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// Transport controls
|
||||
transportControls
|
||||
}
|
||||
.frame(maxWidth: 380)
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
|
||||
private var albumArt: some View {
|
||||
@ -231,31 +175,33 @@ struct ExpandedView: View {
|
||||
Image(nsImage: art)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.frame(width: 260, height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.frame(width: 260, height: 260)
|
||||
.overlay(
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.font(.system(size: 64, weight: .light))
|
||||
.foregroundColor(Self.ink.opacity(0.35))
|
||||
)
|
||||
}
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
|
||||
}
|
||||
|
||||
private var sourceBadge: some View {
|
||||
HStack(spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4))
|
||||
.frame(width: 5, height: 5)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(displaySourceName)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(Self.ink.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
Capsule().fill(Color.white.opacity(0.08))
|
||||
)
|
||||
@ -266,28 +212,25 @@ struct ExpandedView: View {
|
||||
}
|
||||
|
||||
private var transportControls: some View {
|
||||
HStack(spacing: 28) {
|
||||
HStack(spacing: 40) {
|
||||
transportButton(
|
||||
symbol: "backward.fill",
|
||||
size: 16,
|
||||
size: 20,
|
||||
tooltip: L10n.previousTooltip
|
||||
) {
|
||||
state.previousTrack()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Play / pause. Larger, accent button.
|
||||
Button(action: { state.togglePlayPause() }) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5)
|
||||
.frame(width: 48, height: 48)
|
||||
.fill(Self.lime)
|
||||
.frame(width: 56, height: 56)
|
||||
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Self.ink)
|
||||
.offset(x: state.isPlaying ? 0 : 2)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
.offset(x: state.isPlaying ? 0 : 2) // optical nudge for play
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@ -296,7 +239,7 @@ struct ExpandedView: View {
|
||||
|
||||
transportButton(
|
||||
symbol: "forward.fill",
|
||||
size: 16,
|
||||
size: 20,
|
||||
tooltip: L10n.nextTooltip
|
||||
) {
|
||||
state.nextTrack()
|
||||
@ -321,23 +264,23 @@ struct ExpandedView: View {
|
||||
// MARK: - Empty card (nothing playing)
|
||||
|
||||
private var emptyCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 40, weight: .light))
|
||||
.font(.system(size: 44, weight: .light))
|
||||
.foregroundColor(Self.ink.opacity(0.3))
|
||||
|
||||
Text(L10n.nothingPlaying)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Self.ink.opacity(0.7))
|
||||
|
||||
Text(L10n.nothingPlayingHint)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(Self.ink.opacity(0.4))
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: 320)
|
||||
.padding(32)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
|
||||
// MARK: - Warning cards (host outdated / chinese app detected)
|
||||
@ -348,31 +291,31 @@ struct ExpandedView: View {
|
||||
hint: String,
|
||||
tint: Color
|
||||
) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 36, weight: .regular))
|
||||
.font(.system(size: 40, weight: .regular))
|
||||
.foregroundColor(tint)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(Self.ink.opacity(0.9))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(hint)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(Self.ink.opacity(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: 340)
|
||||
.padding(28)
|
||||
.frame(maxWidth: 380)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.white.opacity(0.04))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
@ -416,7 +359,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: 36, height: 36)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.white.opacity(isHovered ? 0.10 : 0.0))
|
||||
|
||||
20
build.sh
20
build.sh
@ -29,24 +29,8 @@ swiftc \
|
||||
# Copy Info.plist
|
||||
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||
|
||||
# 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}"
|
||||
# Ad-hoc sign
|
||||
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||
|
||||
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user