mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
12 KiB
Swift
318 lines
12 KiB
Swift
//
|
||
// ChromeWebSource.swift
|
||
// MioIsland Music Plugin
|
||
//
|
||
// Reads media state from Google Chrome tabs by injecting JavaScript via
|
||
// the Chrome AppleScript "execute javascript" command. The user must have
|
||
// Chrome's View > Developer > "Allow JavaScript from Apple Events" toggle
|
||
// enabled; otherwise the script silently returns nothing and we treat the
|
||
// source as unavailable rather than surfacing an error.
|
||
//
|
||
// Also handles:
|
||
// - YouTube title parsing ("Song - Artist - YouTube")
|
||
// - YouTube thumbnail fallback (img.youtube.com)
|
||
// - Site-aware source naming (YouTube / YouTube Music / SoundCloud /
|
||
// Spotify Web / Google Chrome)
|
||
//
|
||
|
||
import AppKit
|
||
|
||
struct ChromeTrackInfo {
|
||
var title: String
|
||
var artist: String
|
||
var duration: TimeInterval
|
||
var elapsedTime: TimeInterval
|
||
var isPlaying: Bool
|
||
var sourceName: String
|
||
var tabURL: String
|
||
var artworkURL: String?
|
||
}
|
||
|
||
enum ChromeWebSource {
|
||
static let bundleId = "com.google.Chrome"
|
||
|
||
/// Fast check: is Chrome running? JS-injection probing costs ~200ms
|
||
/// even on a hot path; skip entirely when Chrome isn't running.
|
||
static var isRunning: Bool {
|
||
NSWorkspace.shared.runningApplications.contains {
|
||
$0.bundleIdentifier == bundleId
|
||
}
|
||
}
|
||
|
||
// MARK: - Fetch
|
||
|
||
static func fetch() async -> ChromeTrackInfo? {
|
||
// Iterating N tabs × JS injection is O(N) and can be slow with many
|
||
// tabs; cap at 3 seconds so the router doesn't stall the whole cycle.
|
||
let script = """
|
||
tell application "System Events"
|
||
if not (exists process "Google Chrome") then return "NOT_RUNNING"
|
||
end tell
|
||
with timeout of 3 seconds
|
||
tell application "Google Chrome"
|
||
set playingTitle to ""
|
||
set playingURL to ""
|
||
set playingInfo to ""
|
||
repeat with w in windows
|
||
repeat with t in tabs of w
|
||
try
|
||
set mediaInfo to execute t javascript "
|
||
(function() {
|
||
var media = Array.from(document.querySelectorAll('video,audio'));
|
||
if (!media.length) return 'NO_MEDIA';
|
||
var active = media.find(function(item) { return !item.paused && !item.ended; });
|
||
var candidate = active || media.find(function(item) { return !item.ended; }) || media[0];
|
||
if (!candidate) return 'NO_MEDIA';
|
||
var metaImage = document.querySelector('meta[property=\\"og:image\\"], meta[name=\\"twitter:image\\"], link[rel=\\"image_src\\"]');
|
||
var thumbnail = candidate.poster || (metaImage ? (metaImage.content || metaImage.href || '') : '');
|
||
return (active ? 'PLAYING' : 'PAUSED') + '||' + candidate.currentTime + '||' + candidate.duration + '||' + thumbnail;
|
||
})();
|
||
"
|
||
if mediaInfo starts with "PLAYING||" then
|
||
set playingTitle to title of t
|
||
set playingURL to URL of t
|
||
set playingInfo to mediaInfo
|
||
exit repeat
|
||
end if
|
||
end try
|
||
end repeat
|
||
if playingURL is not "" then exit repeat
|
||
end repeat
|
||
if playingURL is not "" then return "PLAYING_TAB||" & playingTitle & "||" & playingURL & "||" & playingInfo
|
||
return "NOT_FOUND"
|
||
end tell
|
||
end timeout
|
||
"""
|
||
|
||
guard let raw = await runAppleScript(script, tag: "chrome") else { return nil }
|
||
if raw == "NOT_RUNNING" || raw == "NOT_FOUND" { return nil }
|
||
|
||
let parts = raw.components(separatedBy: "||")
|
||
guard parts.count >= 5 else { return nil }
|
||
|
||
// Layout from script:
|
||
// [0] PLAYING_TAB
|
||
// [1] raw tab title
|
||
// [2] tab URL
|
||
// [3] PLAYING / PAUSED
|
||
// [4] currentTime
|
||
// [5] duration
|
||
// [6] thumbnail (optional)
|
||
|
||
let rawTitle = parts[1]
|
||
let url = parts[2]
|
||
let state = parts[3]
|
||
let elapsed = parts.count >= 5 ? TimeInterval(parts[4]) ?? 0 : 0
|
||
let duration = parts.count >= 6 ? TimeInterval(parts[5]) ?? 0 : 0
|
||
let artwork = parts.count >= 7 ? parts[6] : ""
|
||
|
||
let parsed = parseYouTubeTitle(rawTitle)
|
||
let sourceName = chromeSourceName(for: url)
|
||
|
||
var artworkURL: String? = artwork.isEmpty ? nil : artwork
|
||
if artworkURL == nil, url.contains("youtube.com"),
|
||
let videoID = extractYouTubeVideoID(from: url) {
|
||
artworkURL = "https://img.youtube.com/vi/\(videoID)/mqdefault.jpg"
|
||
}
|
||
|
||
return ChromeTrackInfo(
|
||
title: parsed.title,
|
||
artist: parsed.artist,
|
||
duration: duration,
|
||
elapsedTime: elapsed,
|
||
isPlaying: state == "PLAYING",
|
||
sourceName: sourceName,
|
||
tabURL: url,
|
||
artworkURL: artworkURL
|
||
)
|
||
}
|
||
|
||
// MARK: - Controls (play/pause via JS; next/prev unsupported without site specific hooks)
|
||
|
||
static func togglePlay(shouldPlay: Bool, preferredURL: String?) async -> Bool {
|
||
let js = controlJavaScript(shouldPlay: shouldPlay)
|
||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
let escapedPreferred = escapeAppleScriptString(preferredURL ?? "")
|
||
|
||
let script = """
|
||
tell application "System Events"
|
||
if not (exists process "Google Chrome") then return "NOT_RUNNING"
|
||
end tell
|
||
tell application "Google Chrome"
|
||
set preferredURL to "\(escapedPreferred)"
|
||
if preferredURL is not "" then
|
||
repeat with w in windows
|
||
repeat with t in tabs of w
|
||
if (URL of t) is preferredURL then
|
||
try
|
||
set actionResult to execute t javascript "\(js)"
|
||
if actionResult is "OK" then return "OK"
|
||
end try
|
||
end if
|
||
end repeat
|
||
end repeat
|
||
end if
|
||
repeat with w in windows
|
||
repeat with t in tabs of w
|
||
try
|
||
set actionResult to execute t javascript "\(js)"
|
||
if actionResult is "OK" then return "OK"
|
||
end try
|
||
end repeat
|
||
end repeat
|
||
return "NO_MEDIA"
|
||
end tell
|
||
"""
|
||
|
||
guard let result = await runAppleScript(script, tag: "chrome-toggle") else { return false }
|
||
return result == "OK"
|
||
}
|
||
|
||
static func seek(to time: TimeInterval, preferredURL: String?) async -> Bool {
|
||
let clamped = max(0, time)
|
||
let js = """
|
||
(function() {
|
||
var media = Array.from(document.querySelectorAll('video,audio'));
|
||
if (!media.length) return 'NO_MEDIA';
|
||
var target = media.find(function(item) { return !item.ended; }) || media[0];
|
||
if (!target) return 'NO_MEDIA';
|
||
try {
|
||
target.currentTime = \(clamped);
|
||
return 'OK';
|
||
} catch (error) {
|
||
return 'ERROR';
|
||
}
|
||
})();
|
||
"""
|
||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
|
||
let escapedPreferred = escapeAppleScriptString(preferredURL ?? "")
|
||
let script = """
|
||
tell application "System Events"
|
||
if not (exists process "Google Chrome") then return "NOT_RUNNING"
|
||
end tell
|
||
tell application "Google Chrome"
|
||
set preferredURL to "\(escapedPreferred)"
|
||
if preferredURL is not "" then
|
||
repeat with w in windows
|
||
repeat with t in tabs of w
|
||
if (URL of t) is preferredURL then
|
||
try
|
||
set actionResult to execute t javascript "\(js)"
|
||
if actionResult is "OK" then return "OK"
|
||
end try
|
||
end if
|
||
end repeat
|
||
end repeat
|
||
end if
|
||
return "NO_MEDIA"
|
||
end tell
|
||
"""
|
||
|
||
guard let result = await runAppleScript(script, tag: "chrome-seek") else { return false }
|
||
return result == "OK"
|
||
}
|
||
|
||
// MARK: - Parsing helpers
|
||
|
||
/// Parse YouTube tab titles. Formats seen in the wild:
|
||
/// "Song Name - Artist - YouTube"
|
||
/// "Song Name - YouTube"
|
||
/// "(123) Song Name - Artist - YouTube" // unread count prefix
|
||
static func parseYouTubeTitle(_ raw: String) -> (title: String, artist: String) {
|
||
var cleaned = raw
|
||
.replacingOccurrences(of: " - YouTube Music", with: "")
|
||
.replacingOccurrences(of: " - YouTube", with: "")
|
||
.trimmingCharacters(in: .whitespaces)
|
||
|
||
// Strip leading "(N) " unread counts YouTube adds to the tab title.
|
||
if cleaned.hasPrefix("(") {
|
||
if let closeParen = cleaned.firstIndex(of: ")") {
|
||
let afterParen = cleaned.index(after: closeParen)
|
||
if afterParen < cleaned.endIndex {
|
||
cleaned = String(cleaned[afterParen...])
|
||
.trimmingCharacters(in: .whitespaces)
|
||
}
|
||
}
|
||
}
|
||
|
||
let parts = cleaned.components(separatedBy: " - ")
|
||
if parts.count >= 2 {
|
||
let title = parts[0].trimmingCharacters(in: .whitespaces)
|
||
let artist = parts[1...].joined(separator: " - ")
|
||
.trimmingCharacters(in: .whitespaces)
|
||
return (title, artist)
|
||
}
|
||
return (cleaned, "")
|
||
}
|
||
|
||
static func extractYouTubeVideoID(from urlString: String) -> String? {
|
||
guard let url = URL(string: urlString),
|
||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||
return nil
|
||
}
|
||
if let v = comps.queryItems?.first(where: { $0.name == "v" })?.value {
|
||
return v
|
||
}
|
||
// youtu.be/<id>
|
||
if url.host == "youtu.be" {
|
||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
return id.isEmpty ? nil : id
|
||
}
|
||
return nil
|
||
}
|
||
|
||
static func chromeSourceName(for url: String) -> String {
|
||
if url.contains("music.youtube.com") { return "YouTube Music" }
|
||
if url.contains("youtube.com") || url.contains("youtu.be") { return "YouTube" }
|
||
if url.contains("soundcloud.com") { return "SoundCloud" }
|
||
if url.contains("open.spotify.com") || url.contains("spotify.com") { return "Spotify Web" }
|
||
if url.contains("music.163.com") { return "网易云音乐 (Web)" }
|
||
if url.contains("y.qq.com") { return "QQ 音乐 (Web)" }
|
||
if url.contains("bilibili.com") { return "哔哩哔哩" }
|
||
return "Google Chrome"
|
||
}
|
||
|
||
// MARK: - JS
|
||
|
||
private static func controlJavaScript(shouldPlay: Bool) -> String {
|
||
if shouldPlay {
|
||
return """
|
||
(function() {
|
||
var media = Array.from(document.querySelectorAll('video,audio'));
|
||
if (!media.length) return 'NO_MEDIA';
|
||
var target = media.find(function(item) { return item.paused && !item.ended; }) || media.find(function(item) { return !item.ended; }) || media[0];
|
||
if (!target) return 'NO_MEDIA';
|
||
try {
|
||
target.play();
|
||
return 'OK';
|
||
} catch (error) {
|
||
return 'ERROR';
|
||
}
|
||
})();
|
||
"""
|
||
}
|
||
return """
|
||
(function() {
|
||
var media = Array.from(document.querySelectorAll('video,audio'));
|
||
if (!media.length) return 'NO_MEDIA';
|
||
var handled = false;
|
||
media.forEach(function(item) {
|
||
if (!item.paused && !item.ended) {
|
||
item.pause();
|
||
handled = true;
|
||
}
|
||
});
|
||
return handled ? 'OK' : 'NO_MATCH';
|
||
})();
|
||
"""
|
||
}
|
||
|
||
private static func escapeAppleScriptString(_ v: String) -> String {
|
||
v.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
}
|
||
}
|