mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc64caec5 | ||
|
|
69776ecec2 | ||
|
|
d5934b06b0 | ||
|
|
113dd31275 | ||
|
|
336b2266e8 | ||
|
|
63885fe121 |
15
Info.plist
15
Info.plist
@ -15,10 +15,21 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.1</string>
|
<string>2.2.2</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>3</string>
|
<string>11</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>MusicPlugin.MusicPlugin</string>
|
<string>MusicPlugin.MusicPlugin</string>
|
||||||
|
<!--
|
||||||
|
Optional size hint for the expanded plugin panel.
|
||||||
|
Host reads these on plugin load and caps the expanded area to the
|
||||||
|
requested dimensions instead of the default ~620x780. Both keys
|
||||||
|
must be present. Range: width 280-1200, height 180-900. Values
|
||||||
|
outside that range are ignored and the host falls back to default.
|
||||||
|
-->
|
||||||
|
<key>MioPluginPreferredWidth</key>
|
||||||
|
<integer>440</integer>
|
||||||
|
<key>MioPluginPreferredHeight</key>
|
||||||
|
<integer>340</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
46
LICENSE-THIRD-PARTY.md
Normal file
46
LICENSE-THIRD-PARTY.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Third-Party Components
|
||||||
|
|
||||||
|
This plugin bundles third-party components under `Resources/`. Each
|
||||||
|
component's copyright and license text is preserved below.
|
||||||
|
|
||||||
|
## MediaRemoteAdapter.framework + mediaremote-adapter.pl
|
||||||
|
|
||||||
|
- Source: https://github.com/Ebullioscopic/Atoll/tree/main/mediaremote-adapter
|
||||||
|
- Copyright (c) 2025 Jonas van den Berg
|
||||||
|
- License: BSD 3-Clause
|
||||||
|
- Purpose: Bypasses the macOS 15.4+ entitlement gate on
|
||||||
|
`MRMediaRemoteGetNowPlayingInfo` by exposing an alternate adapter
|
||||||
|
symbol table through a dedicated private framework wrapper. We
|
||||||
|
invoke the bundled `mediaremote-adapter.pl` as a subprocess and
|
||||||
|
consume its JSON stream.
|
||||||
|
|
||||||
|
### BSD 3-Clause License
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in
|
||||||
|
the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived
|
||||||
|
from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||||
|
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
||||||
|
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||||
|
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
1
Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
1
Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
Versions/Current/MediaRemoteAdapter
|
||||||
1
Resources/MediaRemoteAdapter.framework/Resources
Symbolic link
1
Resources/MediaRemoteAdapter.framework/Resources
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
Versions/Current/Resources
|
||||||
BIN
Resources/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
BIN
Resources/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
Binary file not shown.
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>English</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string></string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.vandenbe.MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1</string>
|
||||||
|
<key>CSResourcesFileMapped</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>files</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/Info.plist</key>
|
||||||
|
<data>
|
||||||
|
M6AF1VWVJ1A/DSliCSjg170FqsY=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
<key>files2</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/Info.plist</key>
|
||||||
|
<dict>
|
||||||
|
<key>hash2</key>
|
||||||
|
<data>
|
||||||
|
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>rules</key>
|
||||||
|
<dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version.plist$</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>rules2</key>
|
||||||
|
<dict>
|
||||||
|
<key>.*\.dSYM($|/)</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>11</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(.*/)?\.DS_Store$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>2000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Info\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^PkgInfo$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^[^/]+$</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^embedded\.provisionprofile$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
Resources/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
1
Resources/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
A
|
||||||
257
Resources/mediaremote-adapter.pl
Executable file
257
Resources/mediaremote-adapter.pl
Executable file
@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Copyright (c) 2025 Jonas van den Berg
|
||||||
|
# This file is licensed under the BSD 3-Clause License.
|
||||||
|
|
||||||
|
# For usage information read below or run the script without arguments.
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use DynaLoader;
|
||||||
|
use File::Spec;
|
||||||
|
use File::Basename;
|
||||||
|
|
||||||
|
sub print_help() {
|
||||||
|
print <<'HELP';
|
||||||
|
Usage:
|
||||||
|
mediaremote-adapter.pl FRAMEWORK_PATH [NOWPLAYING_CLIENT_PATH] [FUNCTION [PARAMS|OPTIONS...]]
|
||||||
|
|
||||||
|
FRAMEWORK_PATH:
|
||||||
|
Absolute path to MediaRemoteAdapter.framework
|
||||||
|
|
||||||
|
NOWPLAYING_CLIENT_PATH (optional):
|
||||||
|
Path to the NowPlayingTestClient executable (used for test mode)
|
||||||
|
|
||||||
|
FUNCTION:
|
||||||
|
stream Streams now playing information (as diff by default)
|
||||||
|
get Prints now playing information once with all available metadata
|
||||||
|
send Sends a command to the now playing application
|
||||||
|
seek Seeks to a specific timeline position
|
||||||
|
shuffle Sets the shuffle mode
|
||||||
|
repeat Sets the repeat mode
|
||||||
|
speed Sets the playback speed
|
||||||
|
|
||||||
|
PARAMS:
|
||||||
|
send(command)
|
||||||
|
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
|
||||||
|
seek(position)
|
||||||
|
position: The timeline position in microseconds
|
||||||
|
shuffle(mode)
|
||||||
|
mode: The shuffle mode
|
||||||
|
repeat(mode)
|
||||||
|
mode: The repeat mode
|
||||||
|
speed(speed)
|
||||||
|
speed: The playback speed
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
get
|
||||||
|
--now: Sets "elapsedTime" to the current elapsed time. By default,
|
||||||
|
this value is the elapsed time at the time of the given "timestamp"
|
||||||
|
stream
|
||||||
|
--no-diff: Disable diffing and always dump all metadata
|
||||||
|
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
|
||||||
|
get, stream
|
||||||
|
--micros: Replaces the following time keys with microsecond equivalents
|
||||||
|
"duration" -> "durationMicros"
|
||||||
|
"elapsedTime" -> "elapsedTimeMicros"
|
||||||
|
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
|
||||||
|
--human-readable, -h: Makes values human-readable. Use only for debugging.
|
||||||
|
The JSON output is pretty-printed and the following keys are adapted:
|
||||||
|
"artworkData" -> Binary data is truncated to a shorter representation
|
||||||
|
|
||||||
|
Examples (script name and framework path omitted):
|
||||||
|
stream --no-diff --debounce=100
|
||||||
|
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
|
||||||
|
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
|
||||||
|
|
||||||
|
HELP
|
||||||
|
exit 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined $ARGV[1]) {
|
||||||
|
print_help();
|
||||||
|
}
|
||||||
|
|
||||||
|
sub fail {
|
||||||
|
my ($error) = @_;
|
||||||
|
print STDERR "$error\n";
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail "Framework path not provided" unless @ARGV >= 1;
|
||||||
|
|
||||||
|
my $framework_path = shift @ARGV;
|
||||||
|
|
||||||
|
# Optionally accept NOWPLAYING_CLIENT path as second argument
|
||||||
|
my $maybe_helper_path = $ARGV[0] // '';
|
||||||
|
if ($maybe_helper_path =~ m{NowPlayingTestClient} || $maybe_helper_path =~ m{/}) {
|
||||||
|
my $helper_path = shift @ARGV;
|
||||||
|
$ENV{NOWPLAYING_CLIENT} = $helper_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
my $framework_basename = File::Basename::basename($framework_path);
|
||||||
|
fail "Provided path is not a framework: $framework_path"
|
||||||
|
unless $framework_basename =~ s/\.framework$//;
|
||||||
|
|
||||||
|
my $framework = File::Spec->catfile($framework_path, $framework_basename);
|
||||||
|
fail "Framework not found at $framework" unless -e $framework;
|
||||||
|
|
||||||
|
my $handle = DynaLoader::dl_load_file($framework, 0)
|
||||||
|
or fail "Failed to load framework: $framework";
|
||||||
|
my $function_name = shift @ARGV or fail "Missing function name";
|
||||||
|
fail "Invalid function name: '$function_name'"
|
||||||
|
unless $function_name eq "stream"
|
||||||
|
|| $function_name eq "get"
|
||||||
|
|| $function_name eq "send"
|
||||||
|
|| $function_name eq "seek"
|
||||||
|
|| $function_name eq "shuffle"
|
||||||
|
|| $function_name eq "repeat"
|
||||||
|
|| $function_name eq "speed"
|
||||||
|
|| $function_name eq "test";
|
||||||
|
|
||||||
|
sub parse_options {
|
||||||
|
my ($start_index) = @_;
|
||||||
|
my %arg_map;
|
||||||
|
my $i = $start_index;
|
||||||
|
while ($i <= $#ARGV) {
|
||||||
|
my $arg = $ARGV[$i];
|
||||||
|
if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) {
|
||||||
|
my $key = $1;
|
||||||
|
my $value = defined $2 ? $2 : undef;
|
||||||
|
$arg_map{$key} = $value;
|
||||||
|
splice @ARGV, $i, 1;
|
||||||
|
}
|
||||||
|
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
|
||||||
|
my @flags = split //, $1;
|
||||||
|
$arg_map{$_} = undef for @flags;
|
||||||
|
splice @ARGV, $i, 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return \%arg_map;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub env_func {
|
||||||
|
my $symbol_name = shift;
|
||||||
|
return "${symbol_name}_env";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_param {
|
||||||
|
my ($func, $index, $name, $value) = @_;
|
||||||
|
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option_unsafe {
|
||||||
|
my ($name, $value) = @_;
|
||||||
|
$name =~ s/-/_/g;
|
||||||
|
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option {
|
||||||
|
my ($options, $key) = @_;
|
||||||
|
my $value = $options->{$key};
|
||||||
|
if (defined $value) {
|
||||||
|
fail "Unexpected value for option '$key'";
|
||||||
|
}
|
||||||
|
set_env_option_unsafe($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub set_env_option_value {
|
||||||
|
my ($options, $key) = @_;
|
||||||
|
my $value = $options->{$key};
|
||||||
|
if (!defined $value) {
|
||||||
|
fail "Missing value for option '$key'";
|
||||||
|
}
|
||||||
|
set_env_option_unsafe($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
my $symbol_name = "adapter_$function_name";
|
||||||
|
if ($function_name eq "send") {
|
||||||
|
my $id = shift @ARGV;
|
||||||
|
fail "Missing ID for '$function_name' command" unless defined $id;
|
||||||
|
set_env_param($symbol_name, 0, "command", "$id");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "stream") {
|
||||||
|
my $options = parse_options(0);
|
||||||
|
foreach my $key (keys %{$options}) {
|
||||||
|
if ($key eq "no-diff") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "debounce") {
|
||||||
|
set_env_option_value($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "micros") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "human-readable" || $key eq "h") {
|
||||||
|
set_env_option($options, "human-readable");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fail "Unrecognized option '$key'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "get") {
|
||||||
|
my $options = parse_options(0);
|
||||||
|
foreach my $key (keys %{$options}) {
|
||||||
|
if ($key eq "micros") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
elsif ($key eq "human-readable" || $key eq "h") {
|
||||||
|
set_env_option($options, "human-readable");
|
||||||
|
}
|
||||||
|
elsif ($key eq "now") {
|
||||||
|
set_env_option($options, $key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
fail "Unrecognized option '$key'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "seek") {
|
||||||
|
my $position = shift @ARGV;
|
||||||
|
fail "Missing position for '$function_name' command" unless defined $position;
|
||||||
|
set_env_param($symbol_name, 0, "position", "$position");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "shuffle") {
|
||||||
|
my $mode = shift @ARGV;
|
||||||
|
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||||
|
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "repeat") {
|
||||||
|
my $mode = shift @ARGV;
|
||||||
|
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||||
|
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "speed") {
|
||||||
|
my $speed = shift @ARGV;
|
||||||
|
fail "Missing speed for '$function_name' command" unless defined $speed;
|
||||||
|
set_env_param($symbol_name, 0, "speed", "$speed");
|
||||||
|
$symbol_name = env_func($symbol_name);
|
||||||
|
}
|
||||||
|
elsif ($function_name eq "test") {
|
||||||
|
$symbol_name = "_adapter_is_it_broken_yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defined shift @ARGV) {
|
||||||
|
fail "Too many arguments";
|
||||||
|
}
|
||||||
|
|
||||||
|
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
|
||||||
|
or fail "Symbol '$symbol_name' not found in $framework";
|
||||||
|
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
|
||||||
|
|
||||||
|
eval {
|
||||||
|
no strict "refs";
|
||||||
|
&{"main::$function_name"}();
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
fail "Error executing $function_name: $@";
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ final class MusicPlugin: NSObject, MioPlugin {
|
|||||||
var id: String { "music-player" }
|
var id: String { "music-player" }
|
||||||
var name: String { "Music Player" }
|
var name: String { "Music Player" }
|
||||||
var icon: String { "music.note" }
|
var icon: String { "music.note" }
|
||||||
var version: String { "2.0.1" }
|
var version: String { "2.0.3" }
|
||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
NSLog("[mio-plugin-music] activate")
|
NSLog("[mio-plugin-music] activate")
|
||||||
|
|||||||
@ -31,6 +31,10 @@ import Combine
|
|||||||
|
|
||||||
enum NowPlayingSourceKind: String {
|
enum NowPlayingSourceKind: String {
|
||||||
case none
|
case none
|
||||||
|
/// Atoll-style MediaRemoteAdapter subprocess stream — bypasses the
|
||||||
|
/// macOS 15.4+ entitlement gate and gives us real-time system Now
|
||||||
|
/// Playing with artwork, duration, and elapsed time.
|
||||||
|
case mediaRemoteAdapter
|
||||||
case mediaRemote
|
case mediaRemote
|
||||||
case spotify
|
case spotify
|
||||||
case appleMusic
|
case appleMusic
|
||||||
@ -71,6 +75,15 @@ final class NowPlayingState: ObservableObject {
|
|||||||
/// 暂不支持,请使用网页版" hint.
|
/// 暂不支持,请使用网页版" hint.
|
||||||
@Published var chineseAppDetected: String?
|
@Published var chineseAppDetected: String?
|
||||||
|
|
||||||
|
/// Parsed synced lyrics from LRCLIB after every track change. Empty
|
||||||
|
/// array = we tried, nothing found (instrumentals / obscure tracks).
|
||||||
|
@Published var syncedLyrics: [LyricLine] = []
|
||||||
|
|
||||||
|
/// Index into `syncedLyrics` for the current playhead. -1 means no
|
||||||
|
/// lyrics loaded OR elapsedTime < first line's timestamp. Updated
|
||||||
|
/// by the playback timer every second.
|
||||||
|
@Published var currentLyricIndex: Int = -1
|
||||||
|
|
||||||
// MARK: - Derived
|
// MARK: - Derived
|
||||||
|
|
||||||
var progress: Double {
|
var progress: Double {
|
||||||
@ -92,6 +105,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private let mediaRemote = MediaRemoteSource()
|
private let mediaRemote = MediaRemoteSource()
|
||||||
|
/// Atoll-style subprocess adapter. Optional because the bundle may be
|
||||||
|
/// missing the Resources/mediaremote-adapter payload (dev builds, old
|
||||||
|
/// plugin versions). When non-nil, it becomes the primary source and
|
||||||
|
/// most of the legacy polling / AppleScript chain stays dormant.
|
||||||
|
private let mediaRemoteAdapter: MediaRemoteAdapterSource? = MediaRemoteAdapterSource()
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var playbackTimer: Timer?
|
private var playbackTimer: Timer?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@ -100,6 +118,20 @@ final class NowPlayingState: ObservableObject {
|
|||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var refreshInFlight = false
|
private var refreshInFlight = false
|
||||||
|
|
||||||
|
/// macOS 15.4+ gates MRMediaRemoteGetNowPlayingInfo behind a private
|
||||||
|
/// entitlement. When the call returns an empty dict we mark the API
|
||||||
|
/// as blocked and skip it for 60 seconds before retrying (macOS minor
|
||||||
|
/// updates can flip the entitlement state, so we don't mark "blocked
|
||||||
|
/// forever"). Saves ~50ms per refresh when blocked, but more importantly
|
||||||
|
/// lets the router hit AppleScript on the first pass instead of the
|
||||||
|
/// second — ~1s faster cold start on restricted systems.
|
||||||
|
private var mediaRemoteBlockedUntil: Date?
|
||||||
|
|
||||||
|
/// NSWorkspace observers for app launch/terminate. When a music app
|
||||||
|
/// opens or closes, refresh immediately — these events beat the poll
|
||||||
|
/// timer by several seconds.
|
||||||
|
private var workspaceObservers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
@ -116,6 +148,17 @@ final class NowPlayingState: ObservableObject {
|
|||||||
Task { @MainActor in self?.refresh() }
|
Task { @MainActor in self?.refresh() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the Atoll-style adapter subprocess if bundled. This is
|
||||||
|
// the PRIMARY low-latency source — on 15.4+ it's the only one that
|
||||||
|
// actually produces live data without AppleScript polling. When
|
||||||
|
// it emits, we short-circuit the router entirely.
|
||||||
|
if let adapter = mediaRemoteAdapter {
|
||||||
|
adapter.onUpdate = { [weak self] info in
|
||||||
|
Task { @MainActor in self?.applyAdapterUpdate(info) }
|
||||||
|
}
|
||||||
|
adapter.start()
|
||||||
|
}
|
||||||
|
|
||||||
// Observe Spotify distributed notifications for instant reaction.
|
// Observe Spotify distributed notifications for instant reaction.
|
||||||
DistributedNotificationCenter.default().addObserver(
|
DistributedNotificationCenter.default().addObserver(
|
||||||
self,
|
self,
|
||||||
@ -124,13 +167,56 @@ final class NowPlayingState: ObservableObject {
|
|||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Observe Apple Music similarly.
|
// Observe Apple Music. macOS 15+ Music.app emits
|
||||||
|
// com.apple.Music.playerInfo; older iTunes emitted
|
||||||
|
// com.apple.iTunes.playerInfo. Register both so track changes are
|
||||||
|
// picked up instantly regardless of which one the current build
|
||||||
|
// broadcasts.
|
||||||
DistributedNotificationCenter.default().addObserver(
|
DistributedNotificationCenter.default().addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(musicStateChanged),
|
selector: #selector(musicStateChanged),
|
||||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
DistributedNotificationCenter.default().addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(musicStateChanged),
|
||||||
|
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Observe app launch / terminate — when Spotify or Music opens, we
|
||||||
|
// want to detect it within the same RunLoop tick rather than waiting
|
||||||
|
// out the 15s safety-net poll.
|
||||||
|
let wsCenter = NSWorkspace.shared.notificationCenter
|
||||||
|
let trackedBundleIds: Set<String> = [
|
||||||
|
SpotifyAppleScript.bundleId,
|
||||||
|
AppleMusicAppleScript.bundleId,
|
||||||
|
ChromeWebSource.bundleId,
|
||||||
|
]
|
||||||
|
let launchToken = wsCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didLaunchApplicationNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
guard
|
||||||
|
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
|
||||||
|
trackedBundleIds.contains(bid)
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in self?.refresh() }
|
||||||
|
}
|
||||||
|
let terminateToken = wsCenter.addObserver(
|
||||||
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
guard
|
||||||
|
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
|
||||||
|
trackedBundleIds.contains(bid)
|
||||||
|
else { return }
|
||||||
|
Task { @MainActor in self?.refresh() }
|
||||||
|
}
|
||||||
|
workspaceObservers = [launchToken, terminateToken]
|
||||||
|
|
||||||
startPolling()
|
startPolling()
|
||||||
refresh()
|
refresh()
|
||||||
@ -146,6 +232,14 @@ final class NowPlayingState: ObservableObject {
|
|||||||
playbackTimer?.invalidate()
|
playbackTimer?.invalidate()
|
||||||
playbackTimer = nil
|
playbackTimer = nil
|
||||||
DistributedNotificationCenter.default().removeObserver(self)
|
DistributedNotificationCenter.default().removeObserver(self)
|
||||||
|
|
||||||
|
let wsCenter = NSWorkspace.shared.notificationCenter
|
||||||
|
for token in workspaceObservers {
|
||||||
|
wsCenter.removeObserver(token)
|
||||||
|
}
|
||||||
|
workspaceObservers.removeAll()
|
||||||
|
|
||||||
|
mediaRemoteAdapter?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func spotifyStateChanged() {
|
@objc private func spotifyStateChanged() {
|
||||||
@ -159,8 +253,51 @@ final class NowPlayingState: ObservableObject {
|
|||||||
// MARK: - Polling
|
// MARK: - Polling
|
||||||
|
|
||||||
private func startPolling() {
|
private func startPolling() {
|
||||||
|
rearmPoll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adaptive poll interval — the event-driven fast paths aren't uniformly
|
||||||
|
/// reliable across players on modern macOS:
|
||||||
|
/// - Spotify: com.spotify.client.PlaybackStateChanged fires instantly
|
||||||
|
/// on every track change → 10s safety-net is plenty.
|
||||||
|
/// - Apple Music: com.apple.Music.playerInfo is NOT reliably broadcast
|
||||||
|
/// on macOS 14+ (Apple stopped posting it in many builds). Combined
|
||||||
|
/// with MediaRemote's 15.4+ entitlement gate, there is literally no
|
||||||
|
/// event source left, so we have to poll. 0.8s gets track changes
|
||||||
|
/// visible inside 1s which is the best we can do without the
|
||||||
|
/// Atoll-style adapter framework.
|
||||||
|
/// - Chrome / web players: no notifications at all. 1.2s poll is a
|
||||||
|
/// reasonable tradeoff between latency and CPU.
|
||||||
|
/// - Idle / nothing playing: 10s is fine — the NSWorkspace launch
|
||||||
|
/// observer will wake us instantly when a music app opens.
|
||||||
|
/// Recomputed and re-armed every time `stickySource` or `isPlaying`
|
||||||
|
/// changes, so the plugin idles cheaply until it has something to track.
|
||||||
|
private var currentPollInterval: TimeInterval = 10.0
|
||||||
|
|
||||||
|
private func adaptivePollInterval() -> TimeInterval {
|
||||||
|
switch stickySource {
|
||||||
|
// Adapter subprocess pushes data in real time — poll only as a
|
||||||
|
// last-resort safety net in case the subprocess silently wedges.
|
||||||
|
case .mediaRemoteAdapter: return 30.0
|
||||||
|
case .appleMusic where isPlaying: return 0.8
|
||||||
|
case .chrome where isPlaying: return 1.2
|
||||||
|
case .spotify where isPlaying: return 3.0 // event-driven, poll is just backup
|
||||||
|
case .mediaRemote where isPlaying: return 3.0
|
||||||
|
default: return 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rearmPoll() {
|
||||||
|
let newInterval = adaptivePollInterval()
|
||||||
|
// Avoid invalidating the timer on every refresh when the interval
|
||||||
|
// didn't actually change — Timer allocs aren't free and the router
|
||||||
|
// calls rearmPoll() after every successful fetch.
|
||||||
|
if let t = pollTimer, t.isValid, abs(newInterval - currentPollInterval) < 0.01 {
|
||||||
|
return
|
||||||
|
}
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
currentPollInterval = newInterval
|
||||||
|
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in self?.refresh() }
|
Task { @MainActor in self?.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,43 +321,193 @@ final class NowPlayingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func routeSources(allowAppleScript: Bool) async {
|
private func routeSources(allowAppleScript: Bool) async {
|
||||||
// Build the order: sticky source first, then the default chain.
|
// Adapter short-circuit: when the subprocess is the sticky source
|
||||||
let defaultOrder: [NowPlayingSourceKind] = [
|
// and we already have a track from it, there's nothing to do here —
|
||||||
.mediaRemote, .spotify, .appleMusic, .chrome
|
// new data will arrive via `applyAdapterUpdate(_:)` whenever it
|
||||||
]
|
// actually changes. Polling on top of an event-driven source just
|
||||||
var order: [NowPlayingSourceKind] = []
|
// wastes AppleScript round-trips.
|
||||||
if stickySource != .none { order.append(stickySource) }
|
if stickySource == .mediaRemoteAdapter,
|
||||||
for kind in defaultOrder where kind != stickySource {
|
!title.isEmpty,
|
||||||
order.append(kind)
|
mediaRemoteAdapter != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for kind in order {
|
// Running-app snapshot — read once per pass so we don't hit the
|
||||||
// Skip AppleScript sources when the host cannot grant permission.
|
// workspace API four times.
|
||||||
if !allowAppleScript, kind != .mediaRemote { continue }
|
let spotifyRunning = SpotifyAppleScript.isRunning
|
||||||
|
let musicRunning = AppleMusicAppleScript.isRunning
|
||||||
|
let chromeRunning = ChromeWebSource.isRunning
|
||||||
|
|
||||||
if let used = await tryFetch(kind) {
|
// MediaRemote gate: on macOS 15.4+ the call returns an empty dict
|
||||||
|
// without entitlement. Cache that for 60s so we don't keep eating
|
||||||
|
// an IPC round-trip per refresh.
|
||||||
|
let now = Date()
|
||||||
|
let mrBlocked: Bool
|
||||||
|
if let until = mediaRemoteBlockedUntil, until > now {
|
||||||
|
mrBlocked = true
|
||||||
|
} else {
|
||||||
|
mrBlocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky-source fast path — if the last successful source is still
|
||||||
|
// a live candidate, try it alone first. One AppleScript round-trip
|
||||||
|
// when music is playing = lowest possible latency path.
|
||||||
|
if stickySource != .none, isCandidateLive(
|
||||||
|
stickySource,
|
||||||
|
spotifyRunning: spotifyRunning,
|
||||||
|
musicRunning: musicRunning,
|
||||||
|
chromeRunning: chromeRunning,
|
||||||
|
mrBlocked: mrBlocked,
|
||||||
|
allowAppleScript: allowAppleScript
|
||||||
|
) {
|
||||||
|
if let used = await tryFetch(stickySource) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.stickySource = used
|
self.stickySource = used
|
||||||
self.updatePlaybackTimer()
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parallel fallback probing. `async let` fans out all live candidates
|
||||||
|
// concurrently — cold start used to serialize: try MR (~50ms, miss on
|
||||||
|
// 15.4+) → try Spotify AppleScript (~100-2000ms) → try Music (~100-2000ms)
|
||||||
|
// → try Chrome (~200ms+). Worst case ~6s. Now they all race and we
|
||||||
|
// use the first non-nil result by priority.
|
||||||
|
async let mrResult: MediaRemoteInfo? = mrBlocked ? nil : mediaRemoteFetch()
|
||||||
|
async let spotifyResult: AppleScriptTrackInfo? = (allowAppleScript && spotifyRunning)
|
||||||
|
? SpotifyAppleScript.fetch() : nil
|
||||||
|
async let musicResult: AppleScriptTrackInfo? = (allowAppleScript && musicRunning)
|
||||||
|
? AppleMusicAppleScript.fetch() : nil
|
||||||
|
async let chromeResult: ChromeTrackInfo? = (allowAppleScript && chromeRunning)
|
||||||
|
? ChromeWebSource.fetch() : nil
|
||||||
|
|
||||||
|
let mr = await mrResult
|
||||||
|
let sp = await spotifyResult
|
||||||
|
let mu = await musicResult
|
||||||
|
let ch = await chromeResult
|
||||||
|
|
||||||
|
// MediaRemote returning empty on 15.4+ marks it blocked for 60s.
|
||||||
|
if !mrBlocked, mr == nil, mediaRemoteLikelyBlocked() {
|
||||||
|
await MainActor.run {
|
||||||
|
self.mediaRemoteBlockedUntil = Date().addingTimeInterval(60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority order for picking the winner among the parallel results.
|
||||||
|
// MediaRemote first (it unifies everything when available). Then
|
||||||
|
// Spotify > Apple Music > Chrome — Spotify desktop tends to have
|
||||||
|
// fuller metadata than web, and Apple Music's AppleScript is slower
|
||||||
|
// so it gets slight demotion when a competing hit exists.
|
||||||
|
if let info = mr, info.hasTrack {
|
||||||
|
await MainActor.run {
|
||||||
|
self.apply(mediaRemote: info)
|
||||||
|
self.stickySource = .mediaRemote
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let info = sp, !info.title.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
self.apply(appleScript: info)
|
||||||
|
self.stickySource = .spotify
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
}
|
||||||
|
if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
|
||||||
|
await MainActor.run { self.albumArt = art }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let info = mu, !info.title.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
self.apply(appleScript: info)
|
||||||
|
self.stickySource = .appleMusic
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
}
|
||||||
|
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
|
||||||
|
await MainActor.run { self.albumArt = art }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let info = ch, !info.title.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
self.apply(chrome: info)
|
||||||
|
self.stickySource = .chrome
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
}
|
||||||
|
if let artURL = info.artworkURL, let url = URL(string: artURL) {
|
||||||
|
if let image = await downloadImage(from: url) {
|
||||||
|
await MainActor.run { self.albumArt = image }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing returned a hit; clear state.
|
// Nothing returned a hit; clear state.
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.clearTrack()
|
self.clearTrack()
|
||||||
self.stickySource = .none
|
self.stickySource = .none
|
||||||
self.updatePlaybackTimer()
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a source could plausibly produce a hit right now given the
|
||||||
|
/// running-app snapshot + MediaRemote blocked state. Used to short-circuit
|
||||||
|
/// the sticky-source fast path — don't probe Spotify if Spotify is closed.
|
||||||
|
private func isCandidateLive(
|
||||||
|
_ kind: NowPlayingSourceKind,
|
||||||
|
spotifyRunning: Bool,
|
||||||
|
musicRunning: Bool,
|
||||||
|
chromeRunning: Bool,
|
||||||
|
mrBlocked: Bool,
|
||||||
|
allowAppleScript: Bool
|
||||||
|
) -> Bool {
|
||||||
|
switch kind {
|
||||||
|
case .none: return false
|
||||||
|
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
|
||||||
|
case .mediaRemote: return !mrBlocked
|
||||||
|
case .spotify: return allowAppleScript && spotifyRunning
|
||||||
|
case .appleMusic: return allowAppleScript && musicRunning
|
||||||
|
case .chrome: return allowAppleScript && chromeRunning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge the MediaRemote callback-style API to async/await so we can
|
||||||
|
/// fan it out alongside the AppleScript sources in `routeSources`.
|
||||||
|
private func mediaRemoteFetch() async -> MediaRemoteInfo? {
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
Task { @MainActor in
|
||||||
|
self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic for "MediaRemote returned empty because Apple blocked us,
|
||||||
|
/// not because no one is playing". If at least one of the known player
|
||||||
|
/// apps is running but MediaRemote came back nil, the cause is almost
|
||||||
|
/// certainly the 15.4+ entitlement gate.
|
||||||
|
private func mediaRemoteLikelyBlocked() -> Bool {
|
||||||
|
SpotifyAppleScript.isRunning ||
|
||||||
|
AppleMusicAppleScript.isRunning ||
|
||||||
|
ChromeWebSource.isRunning
|
||||||
|
}
|
||||||
|
|
||||||
/// Try a single source. Returns the source kind on success, nil on miss.
|
/// Try a single source. Returns the source kind on success, nil on miss.
|
||||||
private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
|
private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .none:
|
case .none:
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
// Push-only source; pull-fetch is a no-op.
|
||||||
|
return nil
|
||||||
|
|
||||||
case .mediaRemote:
|
case .mediaRemote:
|
||||||
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@ -242,6 +529,12 @@ final class NowPlayingState: ObservableObject {
|
|||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
||||||
await MainActor.run { self.apply(appleScript: info) }
|
await MainActor.run { self.apply(appleScript: info) }
|
||||||
|
// Apple Music doesn't expose an artwork URL via AppleScript;
|
||||||
|
// we dump the raw bytes to /tmp and reload. Only refetch when
|
||||||
|
// the track identity actually changes to avoid hammering disk.
|
||||||
|
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
|
||||||
|
await MainActor.run { self.albumArt = art }
|
||||||
|
}
|
||||||
return .appleMusic
|
return .appleMusic
|
||||||
|
|
||||||
case .chrome:
|
case .chrome:
|
||||||
@ -258,6 +551,103 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Apply
|
// MARK: - Apply
|
||||||
|
|
||||||
|
/// Called when the Atoll-style subprocess adapter emits a fresh payload.
|
||||||
|
/// This bypasses the full router — adapter updates are the truest signal
|
||||||
|
/// we have on 15.4+, so we claim sticky-source and publish straight away.
|
||||||
|
private func applyAdapterUpdate(_ info: MediaRemoteInfo) {
|
||||||
|
// Detect track change BEFORE we overwrite the fields.
|
||||||
|
let trackChanged = (self.title != info.title) || (self.artist != info.artist)
|
||||||
|
|
||||||
|
self.title = info.title
|
||||||
|
self.artist = info.artist
|
||||||
|
self.album = info.album
|
||||||
|
self.duration = info.duration
|
||||||
|
self.elapsedTime = info.elapsedTime
|
||||||
|
self.isPlaying = info.isPlaying
|
||||||
|
if let art = info.artwork {
|
||||||
|
self.albumArt = art
|
||||||
|
}
|
||||||
|
// Source name from bundle id for the UI chip. Apple Music → "Apple Music"
|
||||||
|
// etc. Unknown bundle ids fall back to generic "System Media".
|
||||||
|
self.sourceBundleId = info.bundleIdentifier
|
||||||
|
self.sourceName = Self.humanReadableSource(bundleId: info.bundleIdentifier)
|
||||||
|
self.lastChromeTabURL = ""
|
||||||
|
self.stickySource = .mediaRemoteAdapter
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
|
||||||
|
if trackChanged {
|
||||||
|
// Drop stale lyrics and fetch fresh ones from LRCLIB.
|
||||||
|
self.syncedLyrics = []
|
||||||
|
self.currentLyricIndex = -1
|
||||||
|
refreshLyrics()
|
||||||
|
} else {
|
||||||
|
// Same track, but possibly a seek or pause/resume — recompute
|
||||||
|
// the current-lyric index immediately instead of waiting for
|
||||||
|
// the next playback-timer tick.
|
||||||
|
updateCurrentLyricIndex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull synced lyrics from LRCLIB for the current track and publish.
|
||||||
|
/// Runs off the main actor (network I/O) but the @Published update
|
||||||
|
/// hops back to main. No-op when title/artist are missing — spares
|
||||||
|
/// LRCLIB a pointless round-trip.
|
||||||
|
private func refreshLyrics() {
|
||||||
|
let t = title, a = artist, al = album, d = duration
|
||||||
|
guard !t.isEmpty, !a.isEmpty else { return }
|
||||||
|
Task.detached(priority: .utility) { [weak self] in
|
||||||
|
let lines = await LyricsService.fetch(
|
||||||
|
artist: a, title: t, album: al, duration: d
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
// Only adopt if the user hasn't moved on to another track
|
||||||
|
// during the network call (LRCLIB can take 1-2s on misses).
|
||||||
|
guard self.title == t, self.artist == a else { return }
|
||||||
|
self.syncedLyrics = lines
|
||||||
|
self.currentLyricIndex = -1
|
||||||
|
self.updateCurrentLyricIndex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the lyric line whose timestamp ≤ current elapsedTime. Binary
|
||||||
|
/// search since lines are sorted. Only publishes if the index actually
|
||||||
|
/// changed — prevents unnecessary SwiftUI redraws every tick.
|
||||||
|
func updateCurrentLyricIndex() {
|
||||||
|
guard !syncedLyrics.isEmpty else {
|
||||||
|
if currentLyricIndex != -1 { currentLyricIndex = -1 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var newIndex = -1
|
||||||
|
// Linear scan is fine — typical lyric line counts are 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) {
|
private func apply(mediaRemote info: MediaRemoteInfo) {
|
||||||
self.title = info.title
|
self.title = info.title
|
||||||
self.artist = info.artist
|
self.artist = info.artist
|
||||||
@ -272,6 +662,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func apply(appleScript info: AppleScriptTrackInfo) {
|
private func apply(appleScript info: AppleScriptTrackInfo) {
|
||||||
|
// Track changed → drop cached artwork so the source can refetch
|
||||||
|
// (Spotify does URL-based, Apple Music does raw-bytes-via-temp-file).
|
||||||
|
if self.title != info.title || self.artist != info.artist {
|
||||||
|
self.albumArt = nil
|
||||||
|
}
|
||||||
self.title = info.title
|
self.title = info.title
|
||||||
self.artist = info.artist
|
self.artist = info.artist
|
||||||
self.album = info.album
|
self.album = info.album
|
||||||
@ -317,6 +712,7 @@ final class NowPlayingState: ObservableObject {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self, self.isPlaying else { return }
|
guard let self, self.isPlaying else { return }
|
||||||
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
|
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
|
||||||
|
self.updateCurrentLyricIndex()
|
||||||
if self.elapsedTime >= self.duration {
|
if self.elapsedTime >= self.duration {
|
||||||
self.playbackTimer?.invalidate()
|
self.playbackTimer?.invalidate()
|
||||||
self.playbackTimer = nil
|
self.playbackTimer = nil
|
||||||
@ -332,8 +728,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
let shouldPlay = !isPlaying
|
let shouldPlay = !isPlaying
|
||||||
isPlaying = shouldPlay
|
isPlaying = shouldPlay
|
||||||
updatePlaybackTimer()
|
updatePlaybackTimer()
|
||||||
|
rearmPoll() // isPlaying flipped → maybe change poll cadence
|
||||||
|
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.togglePlay()
|
SpotifyAppleScript.togglePlay()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -346,11 +745,13 @@ final class NowPlayingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Confirm from the real source after a short delay.
|
// Confirm from the real source after a short delay.
|
||||||
scheduleRefresh(after: 0.3)
|
scheduleRefresh(after: 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextTrack() {
|
func nextTrack() {
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.next()
|
SpotifyAppleScript.next()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -361,11 +762,13 @@ final class NowPlayingState: ObservableObject {
|
|||||||
case .mediaRemote, .none:
|
case .mediaRemote, .none:
|
||||||
mediaRemote.sendCommand(.nextTrack)
|
mediaRemote.sendCommand(.nextTrack)
|
||||||
}
|
}
|
||||||
scheduleRefresh(after: 0.3)
|
scheduleRefresh(after: 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func previousTrack() {
|
func previousTrack() {
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.previous()
|
SpotifyAppleScript.previous()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -375,7 +778,7 @@ final class NowPlayingState: ObservableObject {
|
|||||||
case .mediaRemote, .none:
|
case .mediaRemote, .none:
|
||||||
mediaRemote.sendCommand(.previousTrack)
|
mediaRemote.sendCommand(.previousTrack)
|
||||||
}
|
}
|
||||||
scheduleRefresh(after: 0.3)
|
scheduleRefresh(after: 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to time: TimeInterval) {
|
func seek(to time: TimeInterval) {
|
||||||
@ -384,6 +787,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
updatePlaybackTimer()
|
updatePlaybackTimer()
|
||||||
|
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.seek(clamped)
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.seek(to: clamped)
|
SpotifyAppleScript.seek(to: clamped)
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -395,7 +800,7 @@ final class NowPlayingState: ObservableObject {
|
|||||||
mediaRemote.setElapsedTime(clamped)
|
mediaRemote.setElapsedTime(clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleRefresh(after: 0.3)
|
scheduleRefresh(after: 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleRefresh(after delay: TimeInterval) {
|
private func scheduleRefresh(after delay: TimeInterval) {
|
||||||
|
|||||||
@ -12,9 +12,18 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
enum AppleMusicAppleScript {
|
enum AppleMusicAppleScript {
|
||||||
private static let bundleId = "com.apple.Music"
|
static let bundleId = "com.apple.Music"
|
||||||
private static let sourceName = "Apple Music"
|
private static let sourceName = "Apple Music"
|
||||||
|
|
||||||
|
/// Fast check: is Music.app actually running? When false, skip
|
||||||
|
/// AppleScript — the 2s `with timeout` still trips but that's two
|
||||||
|
/// wasted seconds per refresh when the user doesn't use Apple Music.
|
||||||
|
static var isRunning: Bool {
|
||||||
|
NSWorkspace.shared.runningApplications.contains {
|
||||||
|
$0.bundleIdentifier == bundleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
static func fetch() async -> AppleScriptTrackInfo? {
|
static func fetch() async -> AppleScriptTrackInfo? {
|
||||||
@ -65,6 +74,45 @@ enum AppleMusicAppleScript {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Artwork
|
||||||
|
|
||||||
|
/// Apple Music stores artwork as embedded raw data (PNG/JPEG) rather than
|
||||||
|
/// a URL. The cheapest way to pull it via AppleScript is to write the
|
||||||
|
/// bytes to a temp file and load NSImage from it. The script writes to
|
||||||
|
/// /tmp/mio-apple-music-art.dat (fixed path — overwrites each call).
|
||||||
|
static func fetchArtwork() async -> NSImage? {
|
||||||
|
let tmpPath = "/tmp/mio-plugin-music-current-art.dat"
|
||||||
|
let script = """
|
||||||
|
tell application "System Events"
|
||||||
|
if not (exists process "Music") then return "NOT_RUNNING"
|
||||||
|
end tell
|
||||||
|
with timeout of 3 seconds
|
||||||
|
tell application "Music"
|
||||||
|
if player state is stopped then return "STOPPED"
|
||||||
|
try
|
||||||
|
set artData to data of artwork 1 of current track
|
||||||
|
set f to open for access POSIX file "\(tmpPath)" with write permission
|
||||||
|
set eof f to 0
|
||||||
|
write artData to f
|
||||||
|
close access f
|
||||||
|
return "OK"
|
||||||
|
on error errMsg
|
||||||
|
try
|
||||||
|
close access POSIX file "\(tmpPath)"
|
||||||
|
end try
|
||||||
|
return "NO_ARTWORK"
|
||||||
|
end try
|
||||||
|
end tell
|
||||||
|
end timeout
|
||||||
|
"""
|
||||||
|
guard let raw = await runAppleScript(script, tag: "music-art"),
|
||||||
|
raw == "OK" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: tmpPath)
|
||||||
|
return await Task.detached { NSImage(contentsOf: url) }.value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Controls
|
// MARK: - Controls
|
||||||
|
|
||||||
static func togglePlay() {
|
static func togglePlay() {
|
||||||
|
|||||||
@ -31,6 +31,14 @@ struct ChromeTrackInfo {
|
|||||||
enum ChromeWebSource {
|
enum ChromeWebSource {
|
||||||
static let bundleId = "com.google.Chrome"
|
static let bundleId = "com.google.Chrome"
|
||||||
|
|
||||||
|
/// Fast check: is Chrome running? JS-injection probing costs ~200ms
|
||||||
|
/// even on a hot path; skip entirely when Chrome isn't running.
|
||||||
|
static var isRunning: Bool {
|
||||||
|
NSWorkspace.shared.runningApplications.contains {
|
||||||
|
$0.bundleIdentifier == bundleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
static func fetch() async -> ChromeTrackInfo? {
|
static func fetch() async -> ChromeTrackInfo? {
|
||||||
|
|||||||
242
Sources/sources/LyricsService.swift
Normal file
242
Sources/sources/LyricsService.swift
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// LyricsService.swift
|
||||||
|
// MioIsland Music Plugin
|
||||||
|
//
|
||||||
|
// Fetches synced lyrics from LRCLIB (https://lrclib.net/docs) and parses
|
||||||
|
// their LRC format into per-line timestamps. Free public API, no auth.
|
||||||
|
//
|
||||||
|
// Approach borrowed from Atoll (github.com/Ebullioscopic/Atoll,
|
||||||
|
// MusicManager.swift: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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
412
Sources/sources/MediaRemoteAdapterSource.swift
Normal file
412
Sources/sources/MediaRemoteAdapterSource.swift
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
//
|
||||||
|
// MediaRemoteAdapterSource.swift
|
||||||
|
// MioIsland Music Plugin
|
||||||
|
//
|
||||||
|
// Bypasses the macOS 15.4+ MRMediaRemoteGetNowPlayingInfo entitlement gate
|
||||||
|
// by running `mediaremote-adapter.pl` (BSD-3-Clause, by Jonas van den Berg)
|
||||||
|
// as a subprocess. The Perl script DynaLoader-loads the bundled
|
||||||
|
// MediaRemoteAdapter.framework binary, which in turn links against Apple's
|
||||||
|
// MediaRemote private framework. Because the entitlement check fires on the
|
||||||
|
// CALLING symbol — which on Apple's side is MR internals, not our process
|
||||||
|
// — the gate is skipped and we get the full now-playing payload.
|
||||||
|
//
|
||||||
|
// The subprocess emits one JSON object per state change to stdout (diff
|
||||||
|
// mode), debounced 50ms. We consume it line-by-line via a NSFileHandle read
|
||||||
|
// observer and update the MediaRemoteInfo callback on the main queue.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// - start() spawns the subprocess exactly once.
|
||||||
|
// - On SIGPIPE / stdout EOF / non-zero exit, we retry after a 2-second
|
||||||
|
// delay. After 3 consecutive crashes within 60s, we stop retrying and
|
||||||
|
// let NowPlayingState fall back to the legacy source chain.
|
||||||
|
// - stop() sends SIGTERM + waits up to 2s + SIGKILL if still alive.
|
||||||
|
//
|
||||||
|
// Credits: MediaRemoteAdapter.framework + mediaremote-adapter.pl
|
||||||
|
// Copyright (c) 2025 Jonas van den Berg. BSD-3-Clause.
|
||||||
|
// Bundled under Resources/mediaremote-adapter/ in this plugin.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Stream payload (subset of adapter output)
|
||||||
|
|
||||||
|
/// The track-level payload. Only the keys we consume are decoded;
|
||||||
|
/// adapter also emits `composer`, `contentItemIdentifier`,
|
||||||
|
/// `radioStationHash`, `timestamp` etc. which we ignore.
|
||||||
|
private struct AdapterStreamPayload: Decodable {
|
||||||
|
var title: String?
|
||||||
|
var artist: String?
|
||||||
|
var album: String?
|
||||||
|
var duration: Double?
|
||||||
|
var elapsedTime: Double?
|
||||||
|
var playbackRate: Double?
|
||||||
|
var playing: Bool?
|
||||||
|
var bundleIdentifier: String?
|
||||||
|
/// Base64-encoded artwork data. JSONDecoder decodes Data from base64
|
||||||
|
/// automatically via its default strategy.
|
||||||
|
var artworkData: Data?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envelope that wraps every line emitted by `stream` mode. Structure is:
|
||||||
|
/// `{"type":"data","diff":<bool>,"payload":{...}}`. `diff: false` means
|
||||||
|
/// this is a full state snapshot (initial baseline OR after track change);
|
||||||
|
/// `diff: true` means only the changed fields are in payload. `get` mode
|
||||||
|
/// emits the payload directly without this envelope.
|
||||||
|
private struct AdapterStreamEnvelope: Decodable {
|
||||||
|
var type: String?
|
||||||
|
var diff: Bool?
|
||||||
|
var payload: AdapterStreamPayload?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source
|
||||||
|
|
||||||
|
final class MediaRemoteAdapterSource {
|
||||||
|
// Configuration
|
||||||
|
private let scriptPath: String
|
||||||
|
private let frameworkPath: String
|
||||||
|
private let debounceMs: Int
|
||||||
|
|
||||||
|
// Callback to NowPlayingState
|
||||||
|
/// Called on the main queue whenever the subprocess emits a payload
|
||||||
|
/// that results in a usable MediaRemoteInfo. Called with nil when the
|
||||||
|
/// subprocess dies and restart is disabled.
|
||||||
|
var onUpdate: ((MediaRemoteInfo) -> Void)?
|
||||||
|
|
||||||
|
// Process state
|
||||||
|
private var process: Process?
|
||||||
|
private var stdoutHandle: FileHandle?
|
||||||
|
private var stderrHandle: FileHandle?
|
||||||
|
private var lineBuffer = Data()
|
||||||
|
|
||||||
|
// Aggregated "current state" — adapter sends diffs, so we merge them
|
||||||
|
// ourselves. Apple Music frequently sends a playbackRate-only diff
|
||||||
|
// when the user pauses, so we need to remember title/artist from earlier.
|
||||||
|
private var currentInfo = MediaRemoteInfo()
|
||||||
|
|
||||||
|
// Crash / restart tracking
|
||||||
|
private var crashTimestamps: [Date] = []
|
||||||
|
private let maxCrashesPer60s = 3
|
||||||
|
private var restartWorkItem: DispatchWorkItem?
|
||||||
|
private var stopped = false
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
/// Initialises the source with paths resolved from the plugin bundle.
|
||||||
|
/// Returns nil if either path is missing — caller should fall back to
|
||||||
|
/// the legacy chain.
|
||||||
|
init?() {
|
||||||
|
// Resolve bundle that contains THIS source's compiled class. Using
|
||||||
|
// Bundle(for:) instead of Bundle.main because the plugin loads into
|
||||||
|
// the host's address space — Bundle.main is the host, not us.
|
||||||
|
let bundle = Bundle(for: PathResolverToken.self)
|
||||||
|
guard let script = bundle.path(forResource: "mediaremote-adapter",
|
||||||
|
ofType: "pl",
|
||||||
|
inDirectory: "mediaremote-adapter")
|
||||||
|
?? bundle.path(forResource: "mediaremote-adapter", ofType: "pl")
|
||||||
|
else {
|
||||||
|
NSLog("[mio-plugin-music] adapter script not found in bundle")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let resourcesRoot = (script as NSString).deletingLastPathComponent
|
||||||
|
let framework = (resourcesRoot as NSString)
|
||||||
|
.appendingPathComponent("MediaRemoteAdapter.framework")
|
||||||
|
guard FileManager.default.fileExists(atPath: framework) else {
|
||||||
|
NSLog("[mio-plugin-music] adapter framework not found at \(framework)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.scriptPath = script
|
||||||
|
self.frameworkPath = framework
|
||||||
|
self.debounceMs = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
stopped = false
|
||||||
|
spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopped = true
|
||||||
|
restartWorkItem?.cancel()
|
||||||
|
restartWorkItem = nil
|
||||||
|
terminateProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func terminateProcess() {
|
||||||
|
guard let proc = process else { return }
|
||||||
|
process = nil
|
||||||
|
stdoutHandle?.readabilityHandler = nil
|
||||||
|
stdoutHandle = nil
|
||||||
|
stderrHandle?.readabilityHandler = nil
|
||||||
|
stderrHandle = nil
|
||||||
|
if proc.isRunning {
|
||||||
|
proc.terminate()
|
||||||
|
// Give it 500ms to exit cleanly, then force.
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
if proc.isRunning {
|
||||||
|
kill(proc.processIdentifier, SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn
|
||||||
|
|
||||||
|
private func spawn() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
proc.arguments = [
|
||||||
|
scriptPath,
|
||||||
|
frameworkPath,
|
||||||
|
"stream",
|
||||||
|
"--debounce=\(debounceMs)"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Minimize inherited env — Perl / DynaLoader doesn't need our full
|
||||||
|
// shell environment. Keep PATH so Perl can find its own modules.
|
||||||
|
proc.environment = [
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"LANG": "en_US.UTF-8"
|
||||||
|
]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
proc.standardOutput = outPipe
|
||||||
|
proc.standardError = errPipe
|
||||||
|
proc.terminationHandler = { [weak self] p in
|
||||||
|
DispatchQueue.main.async { self?.handleTermination(status: p.terminationStatus) }
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutHandle = outPipe.fileHandleForReading
|
||||||
|
stderrHandle = errPipe.fileHandleForReading
|
||||||
|
|
||||||
|
stdoutHandle?.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
DispatchQueue.main.async { self?.ingestStdout(data) }
|
||||||
|
}
|
||||||
|
stderrHandle?.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
if let str = String(data: data, encoding: .utf8) {
|
||||||
|
NSLog("[mio-plugin-music] adapter stderr: \(str.trimmingCharacters(in: .whitespacesAndNewlines))")
|
||||||
|
}
|
||||||
|
_ = self
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
process = proc
|
||||||
|
debugLog("adapter spawned pid=\(proc.processIdentifier)")
|
||||||
|
// Bootstrap — pull current state via one-shot `get`. Covers the
|
||||||
|
// case where the stream subprocess started BEFORE any music app
|
||||||
|
// was opened; in that case the initial stream emit is null/empty,
|
||||||
|
// and no diff comes until something changes. A parallel `get`
|
||||||
|
// catches whatever is playing right now.
|
||||||
|
//
|
||||||
|
// CRITICAL: runs on a BACKGROUND queue because bootstrapGet()
|
||||||
|
// calls Process.waitUntilExit() which blocks synchronously for
|
||||||
|
// 500ms-1s (Perl boot + framework load). Running that on main
|
||||||
|
// freezes the whole UI — looked like a crash/hang on launch.
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||||
|
self?.bootstrapGet()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
debugLog("adapter spawn failed: \(error)")
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs on a BACKGROUND queue. Parses JSON on bg, hops to main for
|
||||||
|
/// the merge + onUpdate so `currentInfo` is only ever mutated on the
|
||||||
|
/// main queue (same queue as stream's parseLine).
|
||||||
|
private func bootstrapGet() {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
proc.arguments = [scriptPath, frameworkPath, "get"]
|
||||||
|
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
|
||||||
|
let outPipe = Pipe()
|
||||||
|
proc.standardOutput = outPipe
|
||||||
|
proc.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
proc.waitUntilExit()
|
||||||
|
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
debugLog("bootstrap get returned empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let payload = try? JSONDecoder().decode(AdapterStreamPayload.self, from: data) else {
|
||||||
|
debugLog("bootstrap get · parse failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.merge(payload)
|
||||||
|
self.debugLog("bootstrap get · title=\(self.currentInfo.title) playing=\(self.currentInfo.isPlaying)")
|
||||||
|
if self.currentInfo.hasTrack {
|
||||||
|
self.onUpdate?(self.currentInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// debugLog runs main-ok from any queue, just logs.
|
||||||
|
debugLog("bootstrap get failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stdout ingestion
|
||||||
|
|
||||||
|
private func ingestStdout(_ chunk: Data) {
|
||||||
|
lineBuffer.append(chunk)
|
||||||
|
// Adapter emits newline-delimited JSON. Parse as many complete
|
||||||
|
// lines as the buffer currently holds.
|
||||||
|
while let nlRange = lineBuffer.firstRange(of: Data([0x0A])) {
|
||||||
|
let lineData = lineBuffer.prefix(upTo: nlRange.lowerBound)
|
||||||
|
lineBuffer.removeSubrange(0 ..< nlRange.upperBound)
|
||||||
|
guard !lineData.isEmpty else { continue }
|
||||||
|
parseLine(Data(lineData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseLine(_ data: Data) {
|
||||||
|
do {
|
||||||
|
let env = try JSONDecoder().decode(AdapterStreamEnvelope.self, from: data)
|
||||||
|
guard env.type == "data" else {
|
||||||
|
debugLog("non-data envelope: \(env.type ?? "nil")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let payload = env.payload else { return }
|
||||||
|
// Full snapshot (diff=false) → reset, then merge, so stale
|
||||||
|
// fields from the previous track don't leak. Diff (default
|
||||||
|
// or true) → merge only the provided fields.
|
||||||
|
if env.diff == false {
|
||||||
|
currentInfo = MediaRemoteInfo()
|
||||||
|
}
|
||||||
|
merge(payload)
|
||||||
|
debugLog("stream rx · diff=\(env.diff ?? true) title=\(currentInfo.title) artist=\(currentInfo.artist) playing=\(currentInfo.isPlaying) hasTrack=\(currentInfo.hasTrack)")
|
||||||
|
if currentInfo.hasTrack {
|
||||||
|
onUpdate?(currentInfo)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if let preview = String(data: data.prefix(80), encoding: .utf8),
|
||||||
|
!preview.hasPrefix("{") && !preview.hasPrefix("null") {
|
||||||
|
debugLog("unparseable line: \(preview)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-based debug log — NSLog / os_log are unreliably filtered on
|
||||||
|
/// macOS 15, and we can't attach Xcode to a plugin loaded from a
|
||||||
|
/// signed host. Writing a line-oriented log to /tmp is the one
|
||||||
|
/// channel that always works for post-mortem inspection.
|
||||||
|
private func debugLog(_ msg: String) {
|
||||||
|
let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(msg)\n"
|
||||||
|
let path = "/tmp/mio-plugin-music-debug.log"
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
if FileManager.default.fileExists(atPath: path),
|
||||||
|
let h = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
|
||||||
|
try? h.seekToEnd()
|
||||||
|
try? h.write(contentsOf: data)
|
||||||
|
try? h.close()
|
||||||
|
} else {
|
||||||
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge an adapter diff into `currentInfo`. Only overwrite fields that
|
||||||
|
/// the payload explicitly provided — leave the rest at their previous
|
||||||
|
/// value so a "just the elapsed time changed" diff doesn't erase title.
|
||||||
|
private func merge(_ payload: AdapterStreamPayload) {
|
||||||
|
if let title = payload.title { currentInfo.title = title }
|
||||||
|
if let artist = payload.artist { currentInfo.artist = artist }
|
||||||
|
if let album = payload.album { currentInfo.album = album }
|
||||||
|
if let duration = payload.duration { currentInfo.duration = duration }
|
||||||
|
if let elapsed = payload.elapsedTime { currentInfo.elapsedTime = elapsed }
|
||||||
|
if let rate = payload.playbackRate { currentInfo.playbackRate = rate }
|
||||||
|
if let playing = payload.playing {
|
||||||
|
currentInfo.isPlaying = playing
|
||||||
|
} else if let rate = payload.playbackRate {
|
||||||
|
// Some diffs only ship playbackRate; derive isPlaying.
|
||||||
|
currentInfo.isPlaying = rate > 0
|
||||||
|
}
|
||||||
|
if let bid = payload.bundleIdentifier { currentInfo.bundleIdentifier = bid }
|
||||||
|
if let art = payload.artworkData, !art.isEmpty {
|
||||||
|
currentInfo.artwork = NSImage(data: art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Termination / restart
|
||||||
|
|
||||||
|
private func handleTermination(status: Int32) {
|
||||||
|
NSLog("[mio-plugin-music] adapter terminated (status=\(status))")
|
||||||
|
stdoutHandle?.readabilityHandler = nil
|
||||||
|
stdoutHandle = nil
|
||||||
|
stderrHandle?.readabilityHandler = nil
|
||||||
|
stderrHandle = nil
|
||||||
|
process = nil
|
||||||
|
currentInfo = MediaRemoteInfo()
|
||||||
|
lineBuffer.removeAll()
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
let now = Date()
|
||||||
|
crashTimestamps.append(now)
|
||||||
|
crashTimestamps.removeAll { now.timeIntervalSince($0) > 60 }
|
||||||
|
if crashTimestamps.count > maxCrashesPer60s {
|
||||||
|
NSLog("[mio-plugin-music] adapter crashed \(crashTimestamps.count) times in 60s — giving up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Exponential-ish backoff: 1s, 2s, 4s by crash count within the window.
|
||||||
|
let delay = min(4.0, pow(2.0, Double(crashTimestamps.count - 1)))
|
||||||
|
let work = DispatchWorkItem { [weak self] in self?.spawn() }
|
||||||
|
restartWorkItem = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport (fire-and-forget short-lived subprocess)
|
||||||
|
|
||||||
|
/// Send a MediaRemote command ID. Uses a short-lived subprocess
|
||||||
|
/// rather than a persistent control channel — keeps the architecture
|
||||||
|
/// simple and matches how Atoll does it.
|
||||||
|
/// Known commands (MRCommand IDs per adapter Perl examples):
|
||||||
|
/// 0=play, 1=pause, 2=togglePlayPause, 3=stop, 4=next, 5=previous
|
||||||
|
func sendCommand(_ id: Int) {
|
||||||
|
runOneShot(["send", String(id)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek to position in seconds. Adapter takes microseconds, so *1e6.
|
||||||
|
func seek(_ seconds: Double) {
|
||||||
|
let micros = Int64(max(0, seconds) * 1_000_000)
|
||||||
|
runOneShot(["seek", String(micros)])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runOneShot(_ args: [String]) {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
proc.arguments = [scriptPath, frameworkPath] + args
|
||||||
|
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
|
||||||
|
let devnull = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
proc.standardOutput = devnull
|
||||||
|
proc.standardError = devnull
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
NSLog("[mio-plugin-music] adapter one-shot failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dummy class used only as a `Bundle(for:)` anchor so we can find our own
|
||||||
|
// plugin bundle without relying on Bundle.main (which is the host app).
|
||||||
|
private final class PathResolverToken {}
|
||||||
@ -28,9 +28,18 @@ struct AppleScriptTrackInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SpotifyAppleScript {
|
enum SpotifyAppleScript {
|
||||||
private static let bundleId = "com.spotify.client"
|
static let bundleId = "com.spotify.client"
|
||||||
private static let sourceName = "Spotify"
|
private static let sourceName = "Spotify"
|
||||||
|
|
||||||
|
/// Fast check: is Spotify actually running? When false, skip AppleScript
|
||||||
|
/// entirely — the 2s `with timeout` would still trip but that's two
|
||||||
|
/// wasted seconds per router pass for an app the user isn't using.
|
||||||
|
static var isRunning: Bool {
|
||||||
|
NSWorkspace.shared.runningApplications.contains {
|
||||||
|
$0.bundleIdentifier == bundleId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
static func fetch() async -> AppleScriptTrackInfo? {
|
static func fetch() async -> AppleScriptTrackInfo? {
|
||||||
|
|||||||
@ -115,4 +115,16 @@ enum L10n {
|
|||||||
static var unknownArtist: String {
|
static var unknownArtist: String {
|
||||||
isChinese ? "未知艺术家" : "Unknown Artist"
|
isChinese ? "未知艺术家" : "Unknown Artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var floatLyricsTooltip: String {
|
||||||
|
isChinese ? "悬浮歌词窗 · 点击切换显示" : "Floating lyrics window · toggle visibility"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var lyricsPlaceholder: String {
|
||||||
|
isChinese ? "歌词暂未接入 · 等待真实数据源" : "Lyrics not wired yet — placeholder"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var lyricsStyleLabel: String {
|
||||||
|
isChinese ? "样式" : "Style"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
369
Sources/ui/DesktopLyricsViews.swift
Normal file
369
Sources/ui/DesktopLyricsViews.swift
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
//
|
||||||
|
// DesktopLyricsViews.swift
|
||||||
|
// MioIsland Music Plugin
|
||||||
|
//
|
||||||
|
// The three floating lyrics window variants. Shared traits:
|
||||||
|
// - NSVisualEffectView-backed blur via .background(.ultraThinMaterial)
|
||||||
|
// when available, fallback to semi-transparent color.
|
||||||
|
// - Draggable via the window's isMovableByWindowBackground (no extra
|
||||||
|
// gesture recognisers needed).
|
||||||
|
// - All text / progress / controls bound to NowPlayingState.shared.
|
||||||
|
// - Lyric lines are PLACEHOLDER text until we wire a real lyrics
|
||||||
|
// source. Only the `lyricLine(_:)` computed below changes when
|
||||||
|
// lyrics data becomes available.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Shared lyric slot helpers
|
||||||
|
|
||||||
|
private enum LyricSlot {
|
||||||
|
case previous
|
||||||
|
case current
|
||||||
|
case next
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the right synced-lyric line for a given slot. Falls back to a
|
||||||
|
/// sensible text when no lyrics are loaded so the window stays readable:
|
||||||
|
/// - previous / next → "······" (tastefully blank)
|
||||||
|
/// - current → track title on cold start, or
|
||||||
|
/// L10n.lyricsPlaceholder when paused / not-found
|
||||||
|
@MainActor
|
||||||
|
private func lyricText(_ slot: LyricSlot, state: NowPlayingState) -> String {
|
||||||
|
let lines = state.syncedLyrics
|
||||||
|
let idx = state.currentLyricIndex
|
||||||
|
|
||||||
|
if !lines.isEmpty {
|
||||||
|
switch slot {
|
||||||
|
case .previous:
|
||||||
|
let i = idx - 1
|
||||||
|
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
|
||||||
|
case .current:
|
||||||
|
if idx >= 0 && idx < lines.count { return lines[idx].text }
|
||||||
|
// Before first lyric line (elapsedTime < first timestamp).
|
||||||
|
return lines.first?.text ?? (state.title.isEmpty ? L10n.lyricsPlaceholder : state.title)
|
||||||
|
case .next:
|
||||||
|
let i = idx + 1
|
||||||
|
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No lyrics loaded / not found — graceful fallback.
|
||||||
|
switch slot {
|
||||||
|
case .previous: return "······"
|
||||||
|
case .current:
|
||||||
|
return state.isPlaying
|
||||||
|
? (state.title.isEmpty ? L10n.unknownTitle : state.title)
|
||||||
|
: L10n.lyricsPlaceholder
|
||||||
|
case .next:
|
||||||
|
return state.artist.isEmpty ? "······" : "— \(state.artist)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared SVG-equivalent transport controls
|
||||||
|
|
||||||
|
private struct MiniControls: View {
|
||||||
|
@ObservedObject var state: NowPlayingState = .shared
|
||||||
|
let playButtonSize: CGFloat
|
||||||
|
let iconButtonSize: CGFloat
|
||||||
|
let filledPlay: Bool // Bar/Karaoke use filled white; Cinema similar
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
button(icon: "backward.fill", size: iconButtonSize) {
|
||||||
|
state.previousTrack()
|
||||||
|
}
|
||||||
|
Button(action: { state.togglePlayPause() }) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(filledPlay ? Color.white.opacity(0.95) : Color.white.opacity(0.9))
|
||||||
|
.frame(width: playButtonSize, height: playButtonSize)
|
||||||
|
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
|
||||||
|
.font(.system(size: playButtonSize * 0.42, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.offset(x: state.isPlaying ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
button(icon: "forward.fill", size: iconButtonSize) {
|
||||||
|
state.nextTrack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func button(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: size * 0.48, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.background(Circle().fill(Color.white.opacity(0.001)))
|
||||||
|
.contentShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let floatBackground = Color(red: 0x12/255, green: 0x10/255, blue: 0x16/255).opacity(0.62)
|
||||||
|
private let floatStroke = Color.white.opacity(0.12)
|
||||||
|
|
||||||
|
/// ViewModifier applying the shared glass chrome (blur + border + shadow).
|
||||||
|
private struct FloatChrome: ViewModifier {
|
||||||
|
let radius: CGFloat
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.fill(floatBackground)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
.strokeBorder(floatStroke, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.5), radius: 30, y: 8)
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rotating vinyl disc (for Bar + Cinema)
|
||||||
|
|
||||||
|
private struct VinylDisc: View {
|
||||||
|
let artwork: NSImage?
|
||||||
|
let isPlaying: Bool
|
||||||
|
let diameter: CGFloat
|
||||||
|
|
||||||
|
/// TimelineView drives rotation off the monotonic wall clock, which is
|
||||||
|
/// immune to SwiftUI re-creating the view (window hide/show, style
|
||||||
|
/// switch). `withAnimation(.repeatForever)` used to lose the animation
|
||||||
|
/// on re-creation and snap to rest. Wall-clock-based rotation just
|
||||||
|
/// always looks right — derive angle from `elapsed % 8s * 45°/s`.
|
||||||
|
@State private var pauseAccumulator: Double = 0
|
||||||
|
@State private var pauseStart: Date? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isPlaying)) { ctx in
|
||||||
|
let elapsed = ctx.date.timeIntervalSinceReferenceDate
|
||||||
|
// 8-second period → 45°/s. Multiplying by 45 and wrapping to
|
||||||
|
// [0, 360) keeps the rotation smooth across many hours without
|
||||||
|
// floating-point drift.
|
||||||
|
let angle = (elapsed * 45.0).truncatingRemainder(dividingBy: 360)
|
||||||
|
disc.rotationEffect(.degrees(angle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var disc: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color.black)
|
||||||
|
if let art = artwork {
|
||||||
|
Image(nsImage: art)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: diameter * 0.55, height: diameter * 0.55)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(LinearGradient(
|
||||||
|
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
|
||||||
|
Color(red: 0.27, green: 0.35, blue: 0.33)],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
))
|
||||||
|
.frame(width: diameter * 0.55, height: diameter * 0.55)
|
||||||
|
}
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black)
|
||||||
|
.frame(width: diameter * 0.1, height: diameter * 0.1)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.overlay(
|
||||||
|
Circle().strokeBorder(Color.white.opacity(0.15), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model 1 · Bar
|
||||||
|
|
||||||
|
struct LyricsBarView: View {
|
||||||
|
@ObservedObject var state: NowPlayingState = .shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 36)
|
||||||
|
|
||||||
|
Text(lyricText(.current, state: state))
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.tracking(-0.1)
|
||||||
|
.foregroundColor(.white.opacity(0.95))
|
||||||
|
.lineLimit(1)
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 6, y: 2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
MiniControls(state: state, playButtonSize: 28, iconButtonSize: 24, filledPlay: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.modifier(FloatChrome(radius: 999))
|
||||||
|
.padding(4) // breathing room so shadow isn't clipped by window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model 2 · Karaoke
|
||||||
|
|
||||||
|
struct LyricsKaraokeView: View {
|
||||||
|
@ObservedObject var state: NowPlayingState = .shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(red: 0.9, green: 0.72, blue: 0.53))
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
Text(state.sourceName.isEmpty
|
||||||
|
? (L10n.isChinese ? "歌词同步" : "Lyrics Sync")
|
||||||
|
: "\(L10n.isChinese ? "歌词同步 · " : "Lyrics · ")\(state.sourceName)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.white.opacity(0.55))
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.white.opacity(0.3))
|
||||||
|
Text("\(state.formattedElapsed) / \(state.formattedDuration)")
|
||||||
|
.font(.system(size: 10.5, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.55))
|
||||||
|
Spacer()
|
||||||
|
Text("⋮⋮ drag")
|
||||||
|
.font(.system(size: 9.5, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.3))
|
||||||
|
}
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
// Current (big)
|
||||||
|
Text(lyricText(.current, state: state))
|
||||||
|
.font(.system(size: 26, weight: .semibold))
|
||||||
|
.tracking(-0.3)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.shadow(color: .black.opacity(0.35), radius: 10, y: 3)
|
||||||
|
|
||||||
|
// Next (faint)
|
||||||
|
Text(lyricText(.next, state: state))
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.42))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.08))
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
|
// Meta + controls row
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
albumArtSmall
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(.white.opacity(0.45))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
MiniControls(state: state, playButtonSize: 32, iconButtonSize: 28, filledPlay: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 22, leading: 26, bottom: 18, trailing: 26))
|
||||||
|
.modifier(FloatChrome(radius: 20))
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var albumArtSmall: some View {
|
||||||
|
if let art = state.albumArt {
|
||||||
|
Image(nsImage: art)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.fill(LinearGradient(
|
||||||
|
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
|
||||||
|
Color(red: 0.27, green: 0.35, blue: 0.33)],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model 3 · Cinema
|
||||||
|
|
||||||
|
struct LyricsCinemaView: View {
|
||||||
|
@ObservedObject var state: NowPlayingState = .shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Prev line (faint)
|
||||||
|
Text(lyricText(.previous, state: state))
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.3))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
// Now line (huge)
|
||||||
|
Text(lyricText(.current, state: state))
|
||||||
|
.font(.system(size: 34, weight: .bold))
|
||||||
|
.tracking(-0.6)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 16, y: 3)
|
||||||
|
|
||||||
|
// Next line (faint)
|
||||||
|
Text(lyricText(.next, state: state))
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.4))
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(0.08))
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 22)
|
||||||
|
|
||||||
|
Text("\(state.title.isEmpty ? L10n.unknownTitle : state.title) · \(state.artist.isEmpty ? L10n.unknownArtist : state.artist)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.white.opacity(0.55))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
MiniControls(state: state, playButtonSize: 30, iconButtonSize: 30, filledPlay: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 40, leading: 40, bottom: 28, trailing: 40))
|
||||||
|
.background(
|
||||||
|
// Faint color wash for cinema feel
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.9, green: 0.72, blue: 0.53).opacity(0.10),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.modifier(FloatChrome(radius: 24))
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
243
Sources/ui/DesktopLyricsWindow.swift
Normal file
243
Sources/ui/DesktopLyricsWindow.swift
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
//
|
||||||
|
// DesktopLyricsWindow.swift
|
||||||
|
// MioIsland Music Plugin
|
||||||
|
//
|
||||||
|
// Floating desktop "lyrics" overlay window — always-on-top, movable by
|
||||||
|
// dragging anywhere on its background, dismissable with Escape. Three
|
||||||
|
// style variants the user can cycle through:
|
||||||
|
//
|
||||||
|
// • Bar (Model 1) — narrow single-line pill, 520×64
|
||||||
|
// • Karaoke (Model 2) — two-line card, current + next, 560×170
|
||||||
|
// • Cinema (Model 3) — 3-line large typography, 640×260
|
||||||
|
//
|
||||||
|
// All variants derive their title/artist/progress/isPlaying from
|
||||||
|
// NowPlayingState.shared. Lyrics data is NOT yet piped in (MediaRemote
|
||||||
|
// adapter doesn't expose lyric timings and there's no public API on
|
||||||
|
// Apple Music / Spotify), so the "lyric line" slot shows a placeholder
|
||||||
|
// string. When a lyric source lands, only the `currentLine` /
|
||||||
|
// `nextLine` / `prevLine` computed properties need to change.
|
||||||
|
//
|
||||||
|
// Window is one per app — `DesktopLyricsWindow.shared` serves toggles
|
||||||
|
// from the ExpandedView's pin button.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Style enum
|
||||||
|
|
||||||
|
enum LyricsStyle: String, CaseIterable, Identifiable {
|
||||||
|
case bar
|
||||||
|
case karaoke
|
||||||
|
case cinema
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var windowSize: CGSize {
|
||||||
|
switch self {
|
||||||
|
case .bar: return CGSize(width: 520, height: 64)
|
||||||
|
case .karaoke: return CGSize(width: 560, height: 170)
|
||||||
|
case .cinema: return CGSize(width: 640, height: 260)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .bar: return L10n.isChinese ? "单行胶囊" : "Bar"
|
||||||
|
case .karaoke: return L10n.isChinese ? "双行卡拉" : "Karaoke"
|
||||||
|
case .cinema: return L10n.isChinese ? "影院大字" : "Cinema"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DesktopLyricsWindow {
|
||||||
|
static let shared = DesktopLyricsWindow()
|
||||||
|
|
||||||
|
private var window: NSWindow?
|
||||||
|
private let stylePrefsKey = "mio.music.lyricsStyle.v1"
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
window?.isVisible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle() {
|
||||||
|
if isVisible {
|
||||||
|
hide()
|
||||||
|
} else {
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
if let existing = window {
|
||||||
|
existing.orderFront(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = loadStyle()
|
||||||
|
let root = DesktopLyricsRootView(initialStyle: style) { [weak self] newStyle in
|
||||||
|
self?.saveStyle(newStyle)
|
||||||
|
self?.resizeTo(newStyle.windowSize)
|
||||||
|
}
|
||||||
|
let host = NSHostingView(rootView: root)
|
||||||
|
|
||||||
|
let win = DraggableBorderlessWindow(
|
||||||
|
contentRect: NSRect(origin: .zero, size: style.windowSize),
|
||||||
|
styleMask: [.borderless],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
win.contentView = host
|
||||||
|
win.backgroundColor = .clear
|
||||||
|
win.isOpaque = false
|
||||||
|
win.hasShadow = true
|
||||||
|
win.isMovableByWindowBackground = true
|
||||||
|
win.level = .floating // always-on-top
|
||||||
|
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
win.ignoresMouseEvents = false // we need clicks for controls
|
||||||
|
win.isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
// Default placement — bottom center of the primary screen, 80pt
|
||||||
|
// above the Dock. User can drag from there.
|
||||||
|
if let screen = NSScreen.main {
|
||||||
|
let f = screen.visibleFrame
|
||||||
|
let x = f.midX - style.windowSize.width / 2
|
||||||
|
let y = f.minY + 80
|
||||||
|
win.setFrameOrigin(NSPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
|
||||||
|
win.orderFront(nil)
|
||||||
|
window = win
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
window?.close()
|
||||||
|
window = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeTo(_ size: CGSize) {
|
||||||
|
guard let win = window else { return }
|
||||||
|
var frame = win.frame
|
||||||
|
frame.origin.y += (frame.size.height - size.height) // anchor to bottom edge
|
||||||
|
frame.size = size
|
||||||
|
win.setFrame(frame, display: true, animate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStyle() -> LyricsStyle {
|
||||||
|
if let raw = UserDefaults.standard.string(forKey: stylePrefsKey),
|
||||||
|
let s = LyricsStyle(rawValue: raw) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return .bar
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveStyle(_ style: LyricsStyle) {
|
||||||
|
UserDefaults.standard.set(style.rawValue, forKey: stylePrefsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Borderless NSWindows can become key (so Escape works) and swallow the
|
||||||
|
// mouse events on our control buttons while still letting drag-background
|
||||||
|
// move the window.
|
||||||
|
private final class DraggableBorderlessWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { false }
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
// Escape → hide (consistent with other floating overlays).
|
||||||
|
if event.keyCode == 53 {
|
||||||
|
DesktopLyricsWindow.shared.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Root view
|
||||||
|
|
||||||
|
/// Hosts the style picker + the currently selected variant. State-changing
|
||||||
|
/// props go up to the window via the `onStyleChange` callback so the
|
||||||
|
/// window can resize.
|
||||||
|
private struct DesktopLyricsRootView: View {
|
||||||
|
@ObservedObject private var state = NowPlayingState.shared
|
||||||
|
@State private var style: LyricsStyle
|
||||||
|
let onStyleChange: (LyricsStyle) -> Void
|
||||||
|
|
||||||
|
init(initialStyle: LyricsStyle, onStyleChange: @escaping (LyricsStyle) -> Void) {
|
||||||
|
_style = State(initialValue: initialStyle)
|
||||||
|
self.onStyleChange = onStyleChange
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Group {
|
||||||
|
switch style {
|
||||||
|
case .bar: LyricsBarView()
|
||||||
|
case .karaoke: LyricsKaraokeView()
|
||||||
|
case .cinema: LyricsCinemaView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
|
||||||
|
// Tiny style-cycle chip in the very corner — minimal, only
|
||||||
|
// visible on hover to stay out of the way.
|
||||||
|
StyleCyclerChip(current: style) { next in
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
style = next
|
||||||
|
}
|
||||||
|
onStyleChange(next)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(
|
||||||
|
width: style.windowSize.width,
|
||||||
|
height: style.windowSize.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cycle chip
|
||||||
|
|
||||||
|
private struct StyleCyclerChip: View {
|
||||||
|
let current: LyricsStyle
|
||||||
|
let onChange: (LyricsStyle) -> Void
|
||||||
|
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
let all = LyricsStyle.allCases
|
||||||
|
let idx = all.firstIndex(of: current) ?? 0
|
||||||
|
onChange(all[(idx + 1) % all.count])
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "rectangle.3.offgrid")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
Text(current.displayName)
|
||||||
|
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(isHovered ? 0.9 : 0.5))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(Color.black.opacity(isHovered ? 0.45 : 0.25))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onHover { isHovered = $0 }
|
||||||
|
.help(L10n.lyricsStyleLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,14 +44,28 @@ struct ExpandedView: View {
|
|||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack(alignment: .center) {
|
||||||
AlbumArtColorExtractor
|
// V2 Immersive backdrop — applied only when actually playing,
|
||||||
.backgroundGradient(for: tintColor)
|
// other modes (empty / warning) use the plain near-black base.
|
||||||
|
if currentMode == .playing {
|
||||||
|
immersiveBackdrop
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
content
|
content
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
||||||
|
// Top-right float-window toggle only when playing.
|
||||||
|
if currentMode == .playing {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
floatWindowToggle
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Self.base)
|
.background(Self.base)
|
||||||
@ -62,6 +76,57 @@ struct ExpandedView: View {
|
|||||||
.animation(.easeInOut(duration: 0.25), value: currentMode)
|
.animation(.easeInOut(duration: 0.25), value: currentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V2 Immersive backdrop: blurred enlarged album art + dark gradient
|
||||||
|
/// overlay (35% → 72% → 92%). Falls back to a solid base when no art.
|
||||||
|
@ViewBuilder
|
||||||
|
private var immersiveBackdrop: some View {
|
||||||
|
if let art = state.albumArt {
|
||||||
|
ZStack {
|
||||||
|
Image(nsImage: art)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.saturation(1.4)
|
||||||
|
.blur(radius: 40, opaque: true)
|
||||||
|
.scaleEffect(1.3)
|
||||||
|
.clipped()
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(stops: [
|
||||||
|
.init(color: Self.base.opacity(0.35), location: 0.0),
|
||||||
|
.init(color: Self.base.opacity(0.72), location: 0.55),
|
||||||
|
.init(color: Self.base.opacity(0.92), location: 1.0)
|
||||||
|
]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AlbumArtColorExtractor.backgroundGradient(for: tintColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin/float button that toggles the desktop lyrics overlay.
|
||||||
|
/// Icon is static (`pip.enter`) — we don't observe the window to keep
|
||||||
|
/// this view free of a @StateObject dependency. The window itself is
|
||||||
|
/// the visibility signal.
|
||||||
|
private var floatWindowToggle: some View {
|
||||||
|
Button {
|
||||||
|
DesktopLyricsWindow.shared.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pip.enter")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Self.ink.opacity(0.85))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
Circle().fill(Color.black.opacity(0.35))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(L10n.floatLyricsTooltip)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - State routing
|
// MARK: - State routing
|
||||||
|
|
||||||
private enum Mode: Equatable {
|
private enum Mode: Equatable {
|
||||||
@ -107,66 +172,57 @@ struct ExpandedView: View {
|
|||||||
// MARK: - Playing card — compact horizontal layout
|
// MARK: - Playing card — compact horizontal layout
|
||||||
|
|
||||||
private var playingCard: some View {
|
private var playingCard: some View {
|
||||||
VStack(spacing: 16) {
|
// V2 Immersive layout — centered column: cover 120 → title → artist
|
||||||
// Hero row: album art left, metadata + source badge right
|
// → progress → controls with outline play button. Matches the
|
||||||
HStack(alignment: .top, spacing: 14) {
|
// Claude Design CodeIsland Music.html V2 spec.
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
// Large centered album art — shadow drops onto blurred backdrop
|
||||||
albumArt
|
albumArt
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
// Source badge, flush right with the artwork top
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
sourceBadge
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
|
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 19, weight: .semibold))
|
||||||
|
.tracking(-0.35)
|
||||||
.foregroundColor(Self.ink)
|
.foregroundColor(Self.ink)
|
||||||
.lineLimit(2)
|
.lineLimit(1)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.shadow(color: .black.opacity(0.35), radius: 6, y: 2)
|
||||||
|
|
||||||
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
|
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
|
||||||
.font(.system(size: 13, weight: .regular))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(Self.ink.opacity(0.75))
|
.foregroundColor(Self.ink.opacity(0.78))
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
if !state.album.isEmpty {
|
|
||||||
Text(state.album)
|
|
||||||
.font(.system(size: 11, weight: .regular))
|
|
||||||
.foregroundColor(Self.ink.opacity(0.45))
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
// Source chip (below artist, subtle)
|
||||||
}
|
sourceBadge
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress + times inline on one row
|
// Progress bar + times
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 8) {
|
||||||
SeekBar(
|
SeekBar(
|
||||||
progress: state.progress,
|
progress: state.progress,
|
||||||
duration: state.duration
|
duration: state.duration
|
||||||
) { newTime in
|
) { newTime in
|
||||||
state.seek(to: newTime)
|
state.seek(to: newTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(state.formattedElapsed)
|
Text(state.formattedElapsed)
|
||||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.foregroundColor(Self.ink.opacity(0.5))
|
.foregroundColor(Self.ink.opacity(0.55))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(state.formattedDuration)
|
Text(state.formattedDuration)
|
||||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.foregroundColor(Self.ink.opacity(0.5))
|
.foregroundColor(Self.ink.opacity(0.55))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
// Transport controls
|
|
||||||
transportControls
|
transportControls
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 460)
|
.frame(maxWidth: 380)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var albumArt: some View {
|
private var albumArt: some View {
|
||||||
@ -175,12 +231,10 @@ struct ExpandedView: View {
|
|||||||
Image(nsImage: art)
|
Image(nsImage: art)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 128, height: 128)
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.fill(Color.white.opacity(0.08))
|
.fill(Color.white.opacity(0.08))
|
||||||
.frame(width: 128, height: 128)
|
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: "music.note")
|
Image(systemName: "music.note")
|
||||||
.font(.system(size: 34, weight: .light))
|
.font(.system(size: 34, weight: .light))
|
||||||
@ -188,7 +242,7 @@ struct ExpandedView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.35), radius: 14, x: 0, y: 6)
|
.shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sourceBadge: some View {
|
private var sourceBadge: some View {
|
||||||
@ -222,15 +276,18 @@ struct ExpandedView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Play / pause — accent button, slightly smaller than v2.0.0 (48 vs 56)
|
// Play / pause — accent button, slightly smaller than v2.0.0 (48 vs 56)
|
||||||
|
// V2 Immersive: outline play button (1.5px 85% white) — lets the
|
||||||
|
// blurred album backdrop breathe through instead of punching a
|
||||||
|
// big lime disc that fights the art.
|
||||||
Button(action: { state.togglePlayPause() }) {
|
Button(action: { state.togglePlayPause() }) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Self.lime)
|
.strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5)
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
|
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
|
||||||
.font(.system(size: 18, weight: .bold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.foregroundColor(.black)
|
.foregroundColor(Self.ink)
|
||||||
.offset(x: state.isPlaying ? 0 : 2) // optical nudge for play glyph
|
.offset(x: state.isPlaying ? 0 : 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
20
build.sh
20
build.sh
@ -29,8 +29,24 @@ swiftc \
|
|||||||
# Copy Info.plist
|
# Copy Info.plist
|
||||||
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||||
|
|
||||||
# Ad-hoc sign
|
# Bundle the MediaRemoteAdapter subprocess payload (Atoll-style).
|
||||||
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
# Resources/ contains `MediaRemoteAdapter.framework` + `mediaremote-adapter.pl`.
|
||||||
|
# Both are BSD-3-Clause by Jonas van den Berg (see LICENSE-THIRD-PARTY.md).
|
||||||
|
# We copy Resources/* into Contents/Resources so MediaRemoteAdapterSource
|
||||||
|
# can find them via Bundle(for:).path(forResource:ofType:).
|
||||||
|
if [ -d "Resources" ]; then
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
|
||||||
|
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
|
||||||
|
# Preserve framework executable bit (cp -R should, but be defensive)
|
||||||
|
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter" 2>/dev/null || true
|
||||||
|
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/mediaremote-adapter.pl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ad-hoc sign the WHOLE bundle including the nested framework. Passing
|
||||||
|
# --deep traverses nested code signatures and re-signs them with our
|
||||||
|
# ad-hoc identity so the framework loads without Gatekeeper complaints
|
||||||
|
# when the plugin is dropped into ~/.config/codeisland/plugins/.
|
||||||
|
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user