904b9b3d-c0eb-42f3-acef-958.../Sources/sources/ChromeWebSource.swift
徐翔宇 336b2266e8 v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.

Changes:

1. Event-driven primary path, poll becomes safety-net
   - Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
     on a Mac that's playing nothing.
   - MediaRemote notifications + DistributedNotificationCenter broadcasts
     (com.spotify.client.PlaybackStateChanged,
      com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
     already handle track changes in <100ms. The 1.5s poll was just
     backup, and now 15s is enough backup.

2. NSWorkspace launch/terminate observers
   - New observers on NSWorkspace.didLaunchApplicationNotification +
     didTerminateApplicationNotification. When Spotify, Apple Music, or
     Chrome launches / quits, refresh fires immediately instead of
     waiting for the next poll. Beats the old path by up to 15s on
     first-launch-of-day scenarios.

3. Running-app gate (NSWorkspace.runningApplications)
   - Each source now exposes `static var isRunning` via
     NSWorkspace.shared.runningApplications.contains(bundleId).
   - Router checks before probing. AppleScript `with timeout of 2 seconds`
     still trips when the target app isn't running, so avoiding those
     probes saves up to 6s per refresh on a clean Mac.

4. MediaRemote 15.4+ entitlement memoization
   - When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
     least one player app is running (likelyBlocked heuristic), mark
     MediaRemote blocked for 60s and skip in the router. Saves ~50ms
     per refresh on restricted macOS versions and lets the first-pass
     AppleScript probe happen without a preceding MR round-trip.
   - Retries every 60s in case the gate state changes (macOS minor
     update / user-granted entitlement).

5. Parallel fallback probing
   - Old router was serial: MediaRemote → Spotify → Music → Chrome.
     Cold start worst-case 4-6s when all three AppleScript sources
     trip their 2s timeouts.
   - New router uses `async let` to fan out every live candidate
     concurrently. First-in-priority-order non-nil result wins.
     Cold start worst-case now ≈ slowest single AppleScript probe.

6. Sticky-source fast path survives
   - When the last-successful source is still a live candidate
     (its app still running, MR still not blocked), try it alone
     first. On steady-state playback this is one round-trip per
     refresh, same as before.

7. Transport control perceived latency
   - scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
     UI already flips optimistically; the 100ms re-sync is enough
     to catch the real app state without feeling laggy.

Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:02:36 +08:00

318 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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: "\\\"")
}
}