904b9b3d-c0eb-42f3-acef-958.../Sources/sources/ChromeWebSource.swift

310 lines
12 KiB
Swift
Raw Permalink Normal View History

v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
//
// 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"
// 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.
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
let script = """
tell application "System Events"
if not (exists process "Google Chrome") then return "NOT_RUNNING"
end tell
with timeout of 3 seconds
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
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
v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
"""
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: "\\\"")
}
}