diff --git a/Info.plist b/Info.plist
index 2591f5c..c89da35 100644
--- a/Info.plist
+++ b/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.0.0
+ 2.0.0
CFBundleVersion
- 1
+ 2
NSPrincipalClass
MusicPlugin.MusicPlugin
diff --git a/README.md b/README.md
index bb864e1..233748b 100644
--- a/README.md
+++ b/README.md
@@ -1,76 +1,175 @@
# Music Player Plugin for MioIsland
-A native plugin that brings real-time music playback information to your MioIsland Notch bar. See what's currently playing across any music app on your Mac without leaving your workflow.
+> **v2.0.0 — full rewrite.** Real Now Playing info from Spotify, Apple Music,
+> Google Chrome (YouTube / SoundCloud / 网页版音乐), with playback controls,
+> draggable seek bar, album art color tint, and a pseudo-spectrum in the header
+> icon. Replaces the v1.0.0 shell that only wired up MediaRemote.
## Features
-- Displays the currently playing track, artist, and album from any macOS music app (Apple Music, Spotify, etc.)
-- Reads system NowPlaying metadata — no need to configure individual apps
-- Lightweight native `.bundle` plugin with minimal resource usage
-- Smooth animated UI that matches the MioIsland design language
-- Appears as a header icon button in the Notch bar
+- **Multi-source playback tracking** with sticky source priority:
+ - MediaRemote (private framework, any app that registers with `MPNowPlayingInfoCenter`)
+ - Spotify desktop (AppleScript)
+ - Apple Music desktop (AppleScript)
+ - Google Chrome tabs (JavaScript injection into `` / `` elements)
+- **Full playback controls** — previous, play/pause, next, draggable seek bar
+- **Album art color tint** — extracts dominant color from artwork, uses it as a
+ soft gradient background in the expanded view
+- **Pseudo-spectrum in the Notch** — three animated vertical bars in the header
+ icon that pulse while music is playing
+- **Bi-lingual** — follows MioIsland's `appLanguage` preference (zh / en)
+- **Graceful degradation:**
+ - Host too old (< v2.1.7) → shows upgrade banner instead of silently failing
+ - Chinese music app running (QQ 音乐 / 网易云 / 酷狗) → shows "desktop not
+ supported, try the web version" hint
-## Screenshots
+## Supported music apps
-*Coming soon*
+| App | Supported? | How |
+|-----|------------|-----|
+| **Spotify** desktop | ✅ Full | AppleScript + MediaRemote |
+| **Apple Music** desktop | ✅ Full | AppleScript + MediaRemote |
+| **YouTube / YouTube Music** in Chrome | ✅ Full | JS injection |
+| **SoundCloud** in Chrome | ✅ Full | JS injection |
+| **Spotify Web Player** in Chrome | ✅ Full | JS injection |
+| **网易云音乐 / QQ 音乐 / 酷狗 (网页版)** in Chrome | ✅ Full | JS injection |
+| **网易云音乐 / QQ 音乐** 新版桌面 app | 🟡 Partial | MediaRemote if the app registers (macOS < 15.4 ok; 15.4+ may silently drop) |
+| **酷狗桌面 app / 酷我 / 咪咕 / 其他国产** | ❌ Not supported | No MediaRemote, no AppleScript API |
+| Any app using `MPNowPlayingInfoCenter` | ✅ | MediaRemote |
+
+> **macOS 15.4+ note:** Apple restricted `MRMediaRemoteGetNowPlayingInfo` to
+> apps with special entitlements. The plugin auto-falls back to AppleScript
+> for Spotify / Apple Music, and to JS injection for Chrome-based sources.
## Installation
-### From MioIsland Plugin Store
+### Prerequisite: MioIsland host v2.1.7 or newer
-1. Visit [miomio.chat](https://miomio.chat)
-2. Find "Music Player" and click Install
-3. MioIsland will automatically download and activate the plugin
-
-### Manual Installation
+This plugin uses AppleScript to talk to Spotify / Apple Music / Chrome.
+macOS requires the host app's `Info.plist` to declare `NSAppleEventsUsageDescription`
+for that. MioIsland added this key in **v2.1.7** — older hosts will show an
+upgrade banner inside the plugin and skip AppleScript sources.
+Upgrade the host:
```bash
-cp -r music-player.bundle ~/.config/codeisland/plugins/
+brew upgrade codeisland
```
-Restart MioIsland to load the plugin.
+Or via MioIsland's in-app update (Sparkle will prompt automatically).
-## Building from Source
+### From MioIsland Plugin Store (recommended)
+
+1. Open **MioIsland Settings → Plugins**
+2. Click **打开插件市场 (Open Plugin Store)** — opens https://miomio.chat
+3. Find **Music Player v2.0.0** and click **Install**
+4. Copy the generated `https://api.miomio.chat/api/i/...` URL
+5. Paste into the **Install from URL** field and click **Install**
+6. Restart MioIsland (menu bar → quit → relaunch)
+
+### Manual installation
+
+```bash
+# Download the latest release from GitHub
+curl -LO https://github.com/MioMioOS/mio-plugin-music/releases/latest/download/music-player.zip
+unzip music-player.zip
+mkdir -p ~/.config/codeisland/plugins/
+cp -R music-player.bundle ~/.config/codeisland/plugins/
+# Restart MioIsland
+```
+
+### First-run permissions
+
+When the plugin fetches track info for the first time, macOS will prompt:
+
+> "Mio Island" would like to control "Spotify".app.
+
+Click **OK**. Do the same for **Music.app** and **Google Chrome** when they
+come up. These permissions are granted to the host app once and remembered
+forever — subsequent launches don't re-prompt.
+
+If you accidentally clicked **Don't Allow**, fix it in:
+**System Settings → Privacy & Security → Automation** → toggle Mio Island on
+for each target app.
+
+### Chrome-specific setup
+
+To get Chrome playback (YouTube, SoundCloud, etc.) working:
+
+1. Open Chrome
+2. Menu bar: **View → Developer → Allow JavaScript from Apple Events**
+3. Check the option (click if unchecked)
+
+This is a one-time setting that lives in Chrome's preferences. Without it,
+AppleScript JS injection will silently return nothing for Chrome tabs.
+
+## Building from source
Requirements:
- macOS 15.0+
-- Xcode Command Line Tools
-- Swift 5.9+
+- Xcode 16+ Command Line Tools
+- Swift 5.10+
```bash
-git clone https://github.com/xmqywx/mio-plugin-music.git
+git clone https://github.com/MioMioOS/mio-plugin-music.git
cd mio-plugin-music
-bash build.sh
+./build.sh # → build/music-player.bundle + build/music-player.zip
+./build.sh install # (not implemented — copy manually)
```
-The build script outputs:
-- `build/music-player.bundle` — the plugin (copy to `~/.config/codeisland/plugins/`)
-- `build/music-player.zip` — compressed bundle for marketplace upload
+Install the build output:
+```bash
+cp -R build/music-player.bundle ~/.config/codeisland/plugins/
+```
-## Plugin Architecture
+Restart MioIsland.
-| File | Purpose |
-|------|---------|
-| `MioPlugin.swift` | Plugin protocol definition |
-| `MusicPlugin.swift` | Main plugin entry point — activate, deactivate, makeView |
-| `MusicPlayerView.swift` | SwiftUI view displaying track info |
-| `MusicHeaderButton.swift` | Header icon button for the Notch bar |
-| `NowPlayingBridge.swift` | Bridges macOS NowPlaying system APIs |
+## Plugin architecture (v2.0.0)
-## How It Works
+```
+Sources/
+├── MioPlugin.swift — plugin SDK protocol (DO NOT MODIFY)
+├── MusicPlugin.swift — principal class (activate / makeView / header slot)
+├── NowPlayingState.swift — @MainActor ObservableObject, source router
+├── sources/
+│ ├── MediaRemoteSource.swift — dlopen private MediaRemote.framework
+│ ├── SpotifyAppleScript.swift
+│ ├── AppleMusicAppleScript.swift
+│ └── ChromeWebSource.swift — JS injection into /
+├── ui/
+│ ├── ExpandedView.swift — main 620×780 panel
+│ ├── HeaderSlotView.swift — 20×20 header icon + pseudo-spectrum
+│ ├── AlbumArtColorExtractor.swift
+│ └── SeekBar.swift
+└── support/
+ ├── ChineseAppDetector.swift — QQ / NetEase / Kugou detection
+ ├── HostVersionCheck.swift — host ≥ v2.1.7 gate
+ └── Localization.swift — zh / en strings
+```
-The plugin uses macOS `MRMediaRemoteGetNowPlayingInfo` API to read the system-wide NowPlaying information. This works with any app that reports playback status to the system, including:
+**Source priority routing:** sticky (last successful) → MediaRemote → Spotify →
+Apple Music → Chrome. First source that returns non-empty playback state wins.
+3-second poll timer drives periodic refresh; Spotify and Music distributed
+notifications trigger immediate refresh for instant reaction.
-- Apple Music
-- Spotify
-- YouTube (in browser)
-- VLC
-- Any app using MPNowPlayingInfoCenter
+## Privacy
+
+The plugin reads only:
+- System-level Now Playing metadata (MediaRemote)
+- Current track info from Spotify / Music / Chrome via AppleScript
+- Bundle IDs of running apps to detect which source is active
+
+It does **not** read:
+- Your listening history
+- Anything outside the "currently playing" state
+- Anything from apps that are not music-related
+
+Nothing is sent to any server. All processing happens locally.
## License
-MIT
+MIT. See LICENSE.
## Author
-[@xmqywx](https://github.com/xmqywx)
+[@xmqywx](https://github.com/xmqywx) — part of the [MioMioOS](https://github.com/MioMioOS)
+official plugin set.
diff --git a/Sources/MusicHeaderButton.swift b/Sources/MusicHeaderButton.swift
deleted file mode 100644
index 49cdaed..0000000
--- a/Sources/MusicHeaderButton.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// MusicHeaderButton.swift
-// MioIsland Music Plugin
-//
-// Small button for the "header" slot — shows a music note icon
-// that posts a notification to open the music plugin view.
-//
-
-import AppKit
-import SwiftUI
-
-/// Notification name that the host app listens for to navigate to a plugin.
-/// The userInfo dict contains ["pluginId": String].
-extension Notification.Name {
- static let openPlugin = Notification.Name("com.codeisland.openPlugin")
-}
-
-struct MusicHeaderButtonView: View {
- @ObservedObject var bridge = NowPlayingBridge.shared
- @State private var isHovered = false
-
- var body: some View {
- Button {
- NotificationCenter.default.post(
- name: .openPlugin,
- object: nil,
- userInfo: ["pluginId": "music-player"]
- )
- } label: {
- Image(systemName: "music.note")
- .font(.system(size: 10))
- .foregroundColor(
- isHovered
- ? Color(red: 1.0, green: 0.4, blue: 0.6) // 荧光粉色
- : (bridge.info.isPlaying ? .white.opacity(0.8) : .white.opacity(0.4))
- )
- .scaleEffect(isHovered ? 1.15 : 1.0)
- .animation(.easeInOut(duration: 0.15), value: isHovered)
- .frame(width: 20, height: 20)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .onHover { hovering in
- isHovered = hovering
- if hovering {
- NSCursor.pointingHand.push()
- } else {
- NSCursor.pop()
- }
- }
- .frame(width: 20, height: 20)
- .fixedSize()
- }
-}
diff --git a/Sources/MusicPlayerView.swift b/Sources/MusicPlayerView.swift
deleted file mode 100644
index eb90d50..0000000
--- a/Sources/MusicPlayerView.swift
+++ /dev/null
@@ -1,116 +0,0 @@
-//
-// MusicPlayerView.swift
-// MioIsland Music Plugin
-//
-// Compact Now Playing UI designed for the notch panel.
-//
-
-import SwiftUI
-
-struct MusicPlayerView: View {
- @ObservedObject var bridge = NowPlayingBridge.shared
-
- var body: some View {
- if bridge.info.title.isEmpty {
- emptyState
- } else {
- nowPlayingView
- }
- }
-
- // MARK: - Now Playing
-
- private var nowPlayingView: some View {
- HStack(spacing: 12) {
- // Album art
- if let artwork = bridge.info.artwork {
- Image(nsImage: artwork)
- .resizable()
- .scaledToFill()
- .frame(width: 48, height: 48)
- .clipShape(RoundedRectangle(cornerRadius: 8))
- } else {
- RoundedRectangle(cornerRadius: 8)
- .fill(Color.white.opacity(0.1))
- .frame(width: 48, height: 48)
- .overlay(
- Image(systemName: "music.note")
- .font(.system(size: 18))
- .foregroundColor(.white.opacity(0.3))
- )
- }
-
- // Info + controls
- VStack(alignment: .leading, spacing: 4) {
- // Title
- Text(bridge.info.title)
- .font(.system(size: 12, weight: .semibold))
- .foregroundColor(.white.opacity(0.95))
- .lineLimit(1)
-
- // Artist
- Text(bridge.info.artist)
- .font(.system(size: 10))
- .foregroundColor(.white.opacity(0.5))
- .lineLimit(1)
-
- // Progress bar
- if bridge.info.duration > 0 {
- GeometryReader { geo in
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 1.5)
- .fill(Color.white.opacity(0.1))
- .frame(height: 3)
- RoundedRectangle(cornerRadius: 1.5)
- .fill(Color.white.opacity(0.6))
- .frame(width: geo.size.width * progress, height: 3)
- }
- }
- .frame(height: 3)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
-
- // Playback controls
- HStack(spacing: 14) {
- controlButton("backward.fill") { bridge.previousTrack() }
- controlButton(bridge.info.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause() }
- .font(.system(size: 14))
- controlButton("forward.fill") { bridge.nextTrack() }
- }
- }
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- }
-
- // MARK: - Empty State
-
- private var emptyState: some View {
- HStack(spacing: 8) {
- Image(systemName: "music.note.list")
- .font(.system(size: 16))
- .foregroundColor(.white.opacity(0.25))
- Text("Nothing playing")
- .font(.system(size: 12))
- .foregroundColor(.white.opacity(0.3))
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 16)
- }
-
- // MARK: - Helpers
-
- private var progress: CGFloat {
- guard bridge.info.duration > 0 else { return 0 }
- return CGFloat(bridge.info.elapsedTime / bridge.info.duration)
- }
-
- private func controlButton(_ symbol: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Image(systemName: symbol)
- .font(.system(size: 11))
- .foregroundColor(.white.opacity(0.7))
- }
- .buttonStyle(.plain)
- }
-}
diff --git a/Sources/MusicPlugin.swift b/Sources/MusicPlugin.swift
index fe7b1b2..14e120a 100644
--- a/Sources/MusicPlugin.swift
+++ b/Sources/MusicPlugin.swift
@@ -2,40 +2,65 @@
// MusicPlugin.swift
// MioIsland Music Plugin
//
-// Principal class for the music-player.bundle plugin.
-// Shows system Now Playing info (Spotify, Apple Music, etc.)
-// with playback controls in the notch.
+// Principal class for the music-player.bundle plugin (v2.0.0).
+//
+// Wires together the data layer (NowPlayingState + sources/*) and the UI
+// layer (ui/*). Loaded at runtime by the host's NativePluginManager via
+// Info.plist -> NSPrincipalClass = "MusicPlugin.MusicPlugin".
+//
+// v2.0.0 is a complete rewrite of the v1.0.0 shell. The old files
+// (NowPlayingBridge / MusicPlayerView / MusicHeaderButton) have been
+// replaced by a layered design:
+//
+// NowPlayingState -> orchestrator + sticky source routing
+// +-> sources/MediaRemoteSource (dlopen private framework)
+// +-> sources/SpotifyAppleScript
+// +-> sources/AppleMusicAppleScript
+// +-> sources/ChromeWebSource (JS injection into video/audio)
+// +-> support/ChineseAppDetector (QQ / NetEase / Kugou)
+// +-> support/HostVersionCheck (host >= 2.1.7 gate)
+//
+// ui/ExpandedView -> main panel (makeView)
+// ui/HeaderSlotView -> 20x20 header icon + pseudo-spectrum
+// ui/AlbumArtColorExtractor + ui/SeekBar
+// support/Localization (zh/en)
//
import AppKit
import SwiftUI
+/// Principal class. Module is `MusicPlugin`, class is `MusicPlugin`, so
+/// Info.plist NSPrincipalClass = "MusicPlugin.MusicPlugin".
final class MusicPlugin: NSObject, MioPlugin {
var id: String { "music-player" }
var name: String { "Music Player" }
var icon: String { "music.note" }
- var version: String { "1.0.0" }
+ var version: String { "2.0.0" }
func activate() {
+ NSLog("[mio-plugin-music] activate")
Task { @MainActor in
- NowPlayingBridge.shared.start()
+ NowPlayingState.shared.start()
}
}
func deactivate() {
+ NSLog("[mio-plugin-music] deactivate")
Task { @MainActor in
- NowPlayingBridge.shared.stop()
+ NowPlayingState.shared.stop()
}
}
func makeView() -> NSView {
- NSHostingView(rootView: MusicPlayerView())
+ let view = NSHostingView(rootView: ExpandedView())
+ view.autoresizingMask = [.width, .height]
+ return view
}
- func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
+ @objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
switch slot {
case "header":
- let view = NSHostingView(rootView: MusicHeaderButtonView())
+ let view = NSHostingView(rootView: HeaderSlotView())
view.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
view.setFrameSize(NSSize(width: 20, height: 20))
return view
diff --git a/Sources/NowPlayingBridge.swift b/Sources/NowPlayingBridge.swift
deleted file mode 100644
index 3ca0437..0000000
--- a/Sources/NowPlayingBridge.swift
+++ /dev/null
@@ -1,129 +0,0 @@
-//
-// NowPlayingBridge.swift
-// MioIsland Music Plugin
-//
-// Reads system Now Playing info via private MediaRemote.framework.
-// Dynamically loads the framework to avoid linking against private APIs.
-//
-
-import AppKit
-import Combine
-
-struct NowPlayingInfo {
- var title: String = ""
- var artist: String = ""
- var album: String = ""
- var artwork: NSImage?
- var duration: Double = 0
- var elapsedTime: Double = 0
- var isPlaying: Bool = false
- var bundleId: String? // source app
-}
-
-@MainActor
-final class NowPlayingBridge: ObservableObject {
- static let shared = NowPlayingBridge()
-
- @Published var info = NowPlayingInfo()
-
- // MediaRemote function pointers
- private var MRMediaRemoteGetNowPlayingInfo: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void)?
- private var MRMediaRemoteSendCommand: (@convention(c) (UInt32, UnsafeMutableRawPointer?) -> Bool)?
- private var MRMediaRemoteRegisterForNowPlayingNotifications: (@convention(c) (DispatchQueue) -> Void)?
-
- private var timer: Timer?
-
- init() {
- loadMediaRemote()
- }
-
- // MARK: - Load Private Framework
-
- private func loadMediaRemote() {
- let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"
- guard let handle = dlopen(path, RTLD_NOW) else { return }
-
- if let ptr = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") {
- MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(ptr, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self)
- }
- if let ptr = dlsym(handle, "MRMediaRemoteSendCommand") {
- MRMediaRemoteSendCommand = unsafeBitCast(ptr, to: (@convention(c) (UInt32, UnsafeMutableRawPointer?) -> Bool).self)
- }
- if let ptr = dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications") {
- MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(ptr, to: (@convention(c) (DispatchQueue) -> Void).self)
- }
- }
-
- // MARK: - Start / Stop
-
- func start() {
- // Register for notifications
- MRMediaRemoteRegisterForNowPlayingNotifications?(DispatchQueue.main)
-
- // Listen for changes
- let nc = NotificationCenter.default
- let names = [
- "kMRMediaRemoteNowPlayingInfoDidChangeNotification",
- "kMRMediaRemoteNowPlayingPlaybackQueueChangedNotification",
- "kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification"
- ]
- for name in names {
- nc.addObserver(self, selector: #selector(nowPlayingChanged), name: NSNotification.Name(name), object: nil)
- }
-
- // Also poll every 2 seconds for elapsed time updates
- timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
- Task { @MainActor in self?.fetchNowPlaying() }
- }
-
- // Initial fetch
- fetchNowPlaying()
- }
-
- func stop() {
- timer?.invalidate()
- timer = nil
- NotificationCenter.default.removeObserver(self)
- }
-
- @objc private func nowPlayingChanged() {
- Task { @MainActor in fetchNowPlaying() }
- }
-
- // MARK: - Fetch
-
- private func fetchNowPlaying() {
- MRMediaRemoteGetNowPlayingInfo?(DispatchQueue.main) { [weak self] dict in
- Task { @MainActor in
- guard let self else { return }
- var info = NowPlayingInfo()
- info.title = dict["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
- info.artist = dict["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
- info.album = dict["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
- info.duration = dict["kMRMediaRemoteNowPlayingInfoDuration"] as? Double ?? 0
- info.elapsedTime = dict["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? Double ?? 0
- info.isPlaying = (dict["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 0) > 0
-
- if let artworkData = dict["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data {
- info.artwork = NSImage(data: artworkData)
- }
-
- self.info = info
- }
- }
- }
-
- // MARK: - Controls (command IDs from MediaRemote.h)
-
- func togglePlayPause() {
- _ = MRMediaRemoteSendCommand?(2, nil) // kMRTogglePlayPause
- }
-
- func nextTrack() {
- _ = MRMediaRemoteSendCommand?(4, nil) // kMRNextTrack
- }
-
- func previousTrack() {
- _ = MRMediaRemoteSendCommand?(5, nil) // kMRPreviousTrack
- }
-}
diff --git a/Sources/NowPlayingState.swift b/Sources/NowPlayingState.swift
new file mode 100644
index 0000000..86961ca
--- /dev/null
+++ b/Sources/NowPlayingState.swift
@@ -0,0 +1,475 @@
+//
+// NowPlayingState.swift
+// MioIsland Music Plugin
+//
+// Single source of truth consumed by the SwiftUI layer. Aggregates four
+// backend sources, in priority order:
+//
+// 1. The most recently successful source (sticky preference so we do not
+// thrash between Spotify / Music / Chrome on every poll).
+// 2. MediaRemote (private framework; falls back on macOS 15.4+ where it
+// returns an empty dictionary without a special entitlement).
+// 3. Spotify desktop via AppleScript.
+// 4. Apple Music via AppleScript.
+// 5. Google Chrome tab via JS injection.
+//
+// Also checks:
+// - Host version (must be ≥ 2.1.7 for NSAppleEventsUsageDescription).
+// - Chinese desktop players (QQ 音乐 / 网易云 / 酷狗) so we can show a
+// "desktop unsupported, use web" state instead of empty UI.
+//
+// Timing:
+// - 3 second poll timer drives periodic refresh.
+// - MediaRemote notifications (when available) trigger immediate refresh.
+// - A 1 second local timer advances elapsedTime while isPlaying is true.
+//
+
+import AppKit
+import Combine
+
+// MARK: - Source enum
+
+enum NowPlayingSourceKind: String {
+ case none
+ case mediaRemote
+ case spotify
+ case appleMusic
+ case chrome
+}
+
+// MARK: - State
+
+@MainActor
+final class NowPlayingState: ObservableObject {
+ static let shared = NowPlayingState()
+
+ // Track info
+ @Published var title: String = ""
+ @Published var artist: String = ""
+ @Published var album: String = ""
+ @Published var albumArt: NSImage?
+
+ /// Populated by Worker B's AlbumArtColorExtractor once albumArt changes.
+ @Published var albumArtColor: NSColor?
+
+ @Published var isPlaying: Bool = false
+ @Published var duration: TimeInterval = 0
+ @Published var elapsedTime: TimeInterval = 0
+
+ /// Human readable source label ("Spotify" / "Apple Music" / "YouTube" / …)
+ @Published var sourceName: String = ""
+
+ /// Bundle identifier of the app that owns the current playback, for
+ /// NSWorkspace icon lookups by the UI layer.
+ @Published var sourceBundleId: String = ""
+
+ /// False when Mio Island host is older than HostVersionCheck.minRequired.
+ /// UI should show an upgrade banner and skip AppleScript sources.
+ @Published var hostVersionOK: Bool = true
+
+ /// Non-nil when a Chinese desktop player is running. UI shows a "桌面端
+ /// 暂不支持,请使用网页版" hint.
+ @Published var chineseAppDetected: String?
+
+ // MARK: - Derived
+
+ var progress: Double {
+ guard duration > 0 else { return 0 }
+ return max(0, min(1, elapsedTime / duration))
+ }
+
+ var formattedElapsed: String { Self.format(elapsedTime) }
+ var formattedDuration: String { Self.format(duration) }
+
+ private static func format(_ t: TimeInterval) -> String {
+ guard t.isFinite, t >= 0 else { return "0:00" }
+ let total = Int(t)
+ let minutes = total / 60
+ let seconds = total % 60
+ return String(format: "%d:%02d", minutes, seconds)
+ }
+
+ // MARK: - Private
+
+ private let mediaRemote = MediaRemoteSource()
+ private var pollTimer: Timer?
+ private var playbackTimer: Timer?
+ private var cancellables = Set()
+ private var stickySource: NowPlayingSourceKind = .none
+ private var lastChromeTabURL: String = ""
+ private var isRunning = false
+ private var refreshInFlight = false
+
+ private init() {}
+
+ // MARK: - Lifecycle
+
+ func start() {
+ guard !isRunning else { return }
+ isRunning = true
+ NSLog("[mio-plugin-music] NowPlayingState.start")
+
+ hostVersionOK = HostVersionCheck.isOK()
+ chineseAppDetected = ChineseAppDetector.detectRunning()
+
+ mediaRemote.registerForNotifications { [weak self] in
+ Task { @MainActor in self?.refresh() }
+ }
+
+ // Observe Spotify distributed notifications for instant reaction.
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(spotifyStateChanged),
+ name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
+ object: nil
+ )
+
+ // Observe Apple Music similarly.
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(musicStateChanged),
+ name: NSNotification.Name("com.apple.Music.playerInfo"),
+ object: nil
+ )
+
+ startPolling()
+ refresh()
+ }
+
+ func stop() {
+ guard isRunning else { return }
+ isRunning = false
+ NSLog("[mio-plugin-music] NowPlayingState.stop")
+
+ pollTimer?.invalidate()
+ pollTimer = nil
+ playbackTimer?.invalidate()
+ playbackTimer = nil
+ DistributedNotificationCenter.default().removeObserver(self)
+ }
+
+ @objc private func spotifyStateChanged() {
+ Task { @MainActor in self.refresh() }
+ }
+
+ @objc private func musicStateChanged() {
+ Task { @MainActor in self.refresh() }
+ }
+
+ // MARK: - Polling
+
+ private func startPolling() {
+ pollTimer?.invalidate()
+ pollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
+ Task { @MainActor in self?.refresh() }
+ }
+ }
+
+ // MARK: - Source router
+
+ private func refresh() {
+ guard !refreshInFlight else { return }
+ refreshInFlight = true
+
+ // Refresh Chinese app detection each pass; user may launch/quit them.
+ chineseAppDetected = ChineseAppDetector.detectRunning()
+
+ let allowAppleScript = hostVersionOK
+
+ Task { [weak self] in
+ guard let self else { return }
+ await self.routeSources(allowAppleScript: allowAppleScript)
+ await MainActor.run { self.refreshInFlight = false }
+ }
+ }
+
+ private func routeSources(allowAppleScript: Bool) async {
+ // Build the order: sticky source first, then the default chain.
+ let defaultOrder: [NowPlayingSourceKind] = [
+ .mediaRemote, .spotify, .appleMusic, .chrome
+ ]
+ var order: [NowPlayingSourceKind] = []
+ if stickySource != .none { order.append(stickySource) }
+ for kind in defaultOrder where kind != stickySource {
+ order.append(kind)
+ }
+
+ for kind in order {
+ // Skip AppleScript sources when the host cannot grant permission.
+ if !allowAppleScript, kind != .mediaRemote { continue }
+
+ if let used = await tryFetch(kind) {
+ await MainActor.run {
+ self.stickySource = used
+ self.updatePlaybackTimer()
+ }
+ return
+ }
+ }
+
+ // Nothing returned a hit; clear state.
+ await MainActor.run {
+ self.clearTrack()
+ self.stickySource = .none
+ self.updatePlaybackTimer()
+ }
+ }
+
+ /// Try a single source. Returns the source kind on success, nil on miss.
+ private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
+ switch kind {
+ case .none:
+ return nil
+
+ case .mediaRemote:
+ let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
+ Task { @MainActor in
+ self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
+ }
+ }
+ guard let info, info.hasTrack else { return nil }
+ await MainActor.run { self.apply(mediaRemote: info) }
+ return .mediaRemote
+
+ case .spotify:
+ guard let info = await SpotifyAppleScript.fetch(), !info.title.isEmpty else { return nil }
+ await MainActor.run { self.apply(appleScript: info) }
+ if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
+ await MainActor.run { self.albumArt = art }
+ }
+ return .spotify
+
+ case .appleMusic:
+ guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
+ await MainActor.run { self.apply(appleScript: info) }
+ return .appleMusic
+
+ case .chrome:
+ guard let info = await ChromeWebSource.fetch(), !info.title.isEmpty else { return nil }
+ await MainActor.run { self.apply(chrome: info) }
+ if let artURL = info.artworkURL, let url = URL(string: artURL) {
+ if let image = await downloadImage(from: url) {
+ await MainActor.run { self.albumArt = image }
+ }
+ }
+ return .chrome
+ }
+ }
+
+ // MARK: - Apply
+
+ private func apply(mediaRemote info: MediaRemoteInfo) {
+ self.title = info.title
+ self.artist = info.artist
+ self.album = info.album
+ self.duration = info.duration
+ self.elapsedTime = info.elapsedTime
+ self.isPlaying = info.isPlaying
+ self.albumArt = info.artwork
+ self.sourceName = "System Media"
+ self.sourceBundleId = info.bundleIdentifier
+ self.lastChromeTabURL = ""
+ }
+
+ private func apply(appleScript info: AppleScriptTrackInfo) {
+ self.title = info.title
+ self.artist = info.artist
+ self.album = info.album
+ self.duration = info.duration
+ self.elapsedTime = info.elapsedTime
+ self.isPlaying = info.isPlaying
+ self.sourceName = info.source
+ self.sourceBundleId = info.bundleId
+ self.lastChromeTabURL = ""
+ }
+
+ private func apply(chrome info: ChromeTrackInfo) {
+ self.title = info.title
+ self.artist = info.artist
+ self.album = ""
+ self.duration = info.duration
+ self.elapsedTime = info.elapsedTime
+ self.isPlaying = info.isPlaying
+ self.sourceName = info.sourceName
+ self.sourceBundleId = ChromeWebSource.bundleId
+ self.lastChromeTabURL = info.tabURL
+ }
+
+ private func clearTrack() {
+ title = ""
+ artist = ""
+ album = ""
+ albumArt = nil
+ isPlaying = false
+ duration = 0
+ elapsedTime = 0
+ sourceName = ""
+ sourceBundleId = ""
+ }
+
+ // MARK: - Playback timer
+
+ private func updatePlaybackTimer() {
+ playbackTimer?.invalidate()
+ playbackTimer = nil
+ guard isPlaying, duration > 0 else { return }
+ playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ guard let self, self.isPlaying else { return }
+ self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
+ if self.elapsedTime >= self.duration {
+ self.playbackTimer?.invalidate()
+ self.playbackTimer = nil
+ }
+ }
+ }
+ }
+
+ // MARK: - Controls
+
+ func togglePlayPause() {
+ // Optimistically flip so the UI feels responsive.
+ let shouldPlay = !isPlaying
+ isPlaying = shouldPlay
+ updatePlaybackTimer()
+
+ switch stickySource {
+ case .spotify:
+ SpotifyAppleScript.togglePlay()
+ case .appleMusic:
+ AppleMusicAppleScript.togglePlay()
+ case .chrome:
+ let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
+ Task { _ = await ChromeWebSource.togglePlay(shouldPlay: shouldPlay, preferredURL: url) }
+ case .mediaRemote, .none:
+ mediaRemote.sendCommand(.togglePlayPause)
+ }
+
+ // Confirm from the real source after a short delay.
+ scheduleRefresh(after: 0.3)
+ }
+
+ func nextTrack() {
+ switch stickySource {
+ case .spotify:
+ SpotifyAppleScript.next()
+ case .appleMusic:
+ AppleMusicAppleScript.next()
+ case .chrome:
+ // Chrome has no generic "next" control across sites.
+ mediaRemote.sendCommand(.nextTrack)
+ case .mediaRemote, .none:
+ mediaRemote.sendCommand(.nextTrack)
+ }
+ scheduleRefresh(after: 0.3)
+ }
+
+ func previousTrack() {
+ switch stickySource {
+ case .spotify:
+ SpotifyAppleScript.previous()
+ case .appleMusic:
+ AppleMusicAppleScript.previous()
+ case .chrome:
+ mediaRemote.sendCommand(.previousTrack)
+ case .mediaRemote, .none:
+ mediaRemote.sendCommand(.previousTrack)
+ }
+ scheduleRefresh(after: 0.3)
+ }
+
+ func seek(to time: TimeInterval) {
+ let clamped = max(0, min(time, duration > 0 ? duration : time))
+ elapsedTime = clamped
+ updatePlaybackTimer()
+
+ switch stickySource {
+ case .spotify:
+ SpotifyAppleScript.seek(to: clamped)
+ case .appleMusic:
+ AppleMusicAppleScript.seek(to: clamped)
+ case .chrome:
+ let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
+ Task { _ = await ChromeWebSource.seek(to: clamped, preferredURL: url) }
+ case .mediaRemote, .none:
+ mediaRemote.setElapsedTime(clamped)
+ }
+
+ scheduleRefresh(after: 0.3)
+ }
+
+ private func scheduleRefresh(after delay: TimeInterval) {
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ self.refresh()
+ }
+ }
+}
+
+// MARK: - Shared AppleScript + network helpers (module-level)
+
+/// Background queue dedicated to NSAppleScript. NSAppleScript is documented
+/// as thread safe only within a single thread, so we keep all invocations
+/// serial on this queue and marshal results back via async continuations.
+private let appleScriptQueue = DispatchQueue(
+ label: "mio-plugin-music.applescript",
+ qos: .userInitiated
+)
+
+/// Execute an AppleScript source string asynchronously. Returns the string
+/// value of the result or nil on error. Error numbers are split into:
+/// -600 : application is not running (normal, silent)
+/// -1728 : Apple Event descriptor error (often benign, silent)
+/// other : logged via NSLog with a tag
+func runAppleScript(_ source: String, tag: String) async -> String? {
+ await withCheckedContinuation { continuation in
+ appleScriptQueue.async {
+ var errorDict: NSDictionary?
+ guard let script = NSAppleScript(source: source) else {
+ continuation.resume(returning: nil)
+ return
+ }
+ let result = script.executeAndReturnError(&errorDict)
+ if let errorDict {
+ let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
+ if num != -600 && num != -1728 {
+ let msg = errorDict[NSAppleScript.errorMessage] as? String ?? ""
+ NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
+ }
+ continuation.resume(returning: nil)
+ return
+ }
+ continuation.resume(returning: result.stringValue)
+ }
+ }
+}
+
+/// Run an AppleScript where we don't care about the return value (transport
+/// controls). Errors still respect the -600 / -1728 silence list.
+func runAppleScriptFireAndForget(_ source: String, tag: String) {
+ appleScriptQueue.async {
+ var errorDict: NSDictionary?
+ guard let script = NSAppleScript(source: source) else { return }
+ _ = script.executeAndReturnError(&errorDict)
+ if let errorDict {
+ let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
+ if num != -600 && num != -1728 {
+ let msg = errorDict[NSAppleScript.errorMessage] as? String ?? ""
+ NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
+ }
+ }
+ }
+}
+
+/// Download image data asynchronously. Returns nil on any failure.
+func downloadImage(from url: URL) async -> NSImage? {
+ await withCheckedContinuation { continuation in
+ URLSession.shared.dataTask(with: url) { data, _, _ in
+ guard let data, let image = NSImage(data: data) else {
+ continuation.resume(returning: nil)
+ return
+ }
+ continuation.resume(returning: image)
+ }.resume()
+ }
+}
diff --git a/Sources/sources/AppleMusicAppleScript.swift b/Sources/sources/AppleMusicAppleScript.swift
new file mode 100644
index 0000000..b78747b
--- /dev/null
+++ b/Sources/sources/AppleMusicAppleScript.swift
@@ -0,0 +1,90 @@
+//
+// AppleMusicAppleScript.swift
+// MioIsland Music Plugin
+//
+// AppleScript bridge for the Music.app (macOS 10.15+). Compared to Spotify
+// the duration field is already in seconds, and artwork is exposed as raw
+// data rather than a URL so we have to ask for the "data size" and then
+// fish the bytes out separately. For simplicity we skip artwork here and
+// let MediaRemote (when available) or a future enhancement provide covers.
+//
+
+import AppKit
+
+enum AppleMusicAppleScript {
+ private static let bundleId = "com.apple.Music"
+ private static let sourceName = "Apple Music"
+
+ // MARK: - Fetch
+
+ static func fetch() async -> AppleScriptTrackInfo? {
+ let script = """
+ tell application "System Events"
+ if not (exists process "Music") then return "NOT_RUNNING"
+ end tell
+ tell application "Music"
+ if player state is playing or player state is paused then
+ set trackName to name of current track
+ set trackArtist to artist of current track
+ set trackAlbum to album of current track
+ set trackDuration to duration of current track
+ set trackPosition to player position
+ set stateString to "PAUSED"
+ if player state is playing then set stateString to "PLAYING"
+ return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition
+ else
+ return "NOT_PLAYING"
+ end if
+ end tell
+ """
+
+ guard let raw = await runAppleScript(script, tag: "music") else { return nil }
+ if raw == "NOT_RUNNING" || raw == "NOT_PLAYING" { return nil }
+
+ let parts = raw.components(separatedBy: "||")
+ guard parts.count >= 6 else { return nil }
+
+ return AppleScriptTrackInfo(
+ title: parts[1],
+ artist: parts[2],
+ album: parts[3],
+ duration: TimeInterval(parts[4]) ?? 0,
+ elapsedTime: TimeInterval(parts[5]) ?? 0,
+ isPlaying: parts[0] == "PLAYING",
+ artworkURL: nil,
+ source: sourceName,
+ bundleId: bundleId
+ )
+ }
+
+ // MARK: - Controls
+
+ static func togglePlay() {
+ runAppleScriptFireAndForget(
+ "tell application \"Music\" to playpause",
+ tag: "music-toggle"
+ )
+ }
+
+ static func next() {
+ runAppleScriptFireAndForget(
+ "tell application \"Music\" to next track",
+ tag: "music-next"
+ )
+ }
+
+ static func previous() {
+ runAppleScriptFireAndForget(
+ "tell application \"Music\" to previous track",
+ tag: "music-prev"
+ )
+ }
+
+ static func seek(to time: TimeInterval) {
+ let clamped = max(0, time)
+ runAppleScriptFireAndForget(
+ "tell application \"Music\" to set player position to \(clamped)",
+ tag: "music-seek"
+ )
+ }
+}
diff --git a/Sources/sources/ChromeWebSource.swift b/Sources/sources/ChromeWebSource.swift
new file mode 100644
index 0000000..23cfa85
--- /dev/null
+++ b/Sources/sources/ChromeWebSource.swift
@@ -0,0 +1,305 @@
+//
+// 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? {
+ let script = """
+ tell application "System Events"
+ if not (exists process "Google Chrome") then return "NOT_RUNNING"
+ end tell
+ 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
+ """
+
+ 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/
+ 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: "\\\"")
+ }
+}
diff --git a/Sources/sources/MediaRemoteSource.swift b/Sources/sources/MediaRemoteSource.swift
new file mode 100644
index 0000000..7c19011
--- /dev/null
+++ b/Sources/sources/MediaRemoteSource.swift
@@ -0,0 +1,193 @@
+//
+// MediaRemoteSource.swift
+// MioIsland Music Plugin
+//
+// Dynamically loads /System/Library/PrivateFrameworks/MediaRemote.framework
+// so we can read system Now Playing info without linking a private API.
+//
+// Known caveat on macOS 15.4+: Apple restricted MRMediaRemoteGetNowPlayingInfo
+// to callers with a specific entitlement. For regular third party apps the
+// callback returns an empty dictionary. When this happens we surface the
+// empty result and NowPlayingState falls through to AppleScript sources.
+//
+
+import AppKit
+
+// MARK: - MediaRemote function signatures
+
+private typealias MRMediaRemoteRegisterForNowPlayingNotificationsFunction =
+ @convention(c) (DispatchQueue) -> Void
+private typealias MRMediaRemoteGetNowPlayingInfoFunction =
+ @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
+private typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction =
+ @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
+private typealias MRMediaRemoteSendCommandFunction =
+ @convention(c) (UInt32, UnsafeMutableRawPointer?) -> Bool
+private typealias MRMediaRemoteSetElapsedTimeFunction =
+ @convention(c) (Double) -> Void
+
+// MARK: - Command enum (public API of this file)
+
+enum MediaRemoteCommand: UInt32 {
+ case play = 0
+ case pause = 1
+ case togglePlayPause = 2
+ case stop = 3
+ case nextTrack = 4
+ case previousTrack = 5
+}
+
+// MARK: - Payload struct
+
+struct MediaRemoteInfo {
+ var title: String = ""
+ var artist: String = ""
+ var album: String = ""
+ var artwork: NSImage?
+ var duration: TimeInterval = 0
+ var elapsedTime: TimeInterval = 0
+ var playbackRate: Double = 0
+ var isPlaying: Bool = false
+ var bundleIdentifier: String = ""
+
+ var hasTrack: Bool { !title.isEmpty }
+}
+
+// MARK: - Info dictionary keys
+
+private let kTitle = "kMRMediaRemoteNowPlayingInfoTitle"
+private let kArtist = "kMRMediaRemoteNowPlayingInfoArtist"
+private let kAlbum = "kMRMediaRemoteNowPlayingInfoAlbum"
+private let kArtworkData = "kMRMediaRemoteNowPlayingInfoArtworkData"
+private let kDuration = "kMRMediaRemoteNowPlayingInfoDuration"
+private let kElapsedTime = "kMRMediaRemoteNowPlayingInfoElapsedTime"
+private let kPlaybackRate = "kMRMediaRemoteNowPlayingInfoPlaybackRate"
+
+// MARK: - Source
+
+final class MediaRemoteSource {
+ private var handle: UnsafeMutableRawPointer?
+ private var registerFn: MRMediaRemoteRegisterForNowPlayingNotificationsFunction?
+ private var getInfoFn: MRMediaRemoteGetNowPlayingInfoFunction?
+ private var getIsPlayingFn: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction?
+ private var sendCommandFn: MRMediaRemoteSendCommandFunction?
+ private var setElapsedTimeFn: MRMediaRemoteSetElapsedTimeFunction?
+
+ private var notificationObservers: [NSObjectProtocol] = []
+
+ init() {
+ loadFramework()
+ }
+
+ deinit {
+ for token in notificationObservers {
+ NotificationCenter.default.removeObserver(token)
+ }
+ if let handle {
+ dlclose(handle)
+ }
+ }
+
+ // MARK: - Loading
+
+ private func loadFramework() {
+ let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"
+ guard let h = dlopen(path, RTLD_NOW) else {
+ NSLog("[mio-plugin-music] MediaRemote dlopen failed")
+ return
+ }
+ handle = h
+
+ if let sym = dlsym(h, "MRMediaRemoteRegisterForNowPlayingNotifications") {
+ registerFn = unsafeBitCast(sym, to: MRMediaRemoteRegisterForNowPlayingNotificationsFunction.self)
+ }
+ if let sym = dlsym(h, "MRMediaRemoteGetNowPlayingInfo") {
+ getInfoFn = unsafeBitCast(sym, to: MRMediaRemoteGetNowPlayingInfoFunction.self)
+ }
+ if let sym = dlsym(h, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") {
+ getIsPlayingFn = unsafeBitCast(sym, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
+ }
+ if let sym = dlsym(h, "MRMediaRemoteSendCommand") {
+ sendCommandFn = unsafeBitCast(sym, to: MRMediaRemoteSendCommandFunction.self)
+ }
+ if let sym = dlsym(h, "MRMediaRemoteSetElapsedTime") {
+ setElapsedTimeFn = unsafeBitCast(sym, to: MRMediaRemoteSetElapsedTimeFunction.self)
+ }
+ }
+
+ // MARK: - Public API
+
+ /// Pull the current Now Playing dictionary. completion runs on the main queue.
+ /// On macOS 15.4+ the callback may deliver an empty dict; caller should
+ /// treat a nil MediaRemoteInfo (or one where hasTrack is false) as a miss.
+ func fetchInfo(completion: @escaping (MediaRemoteInfo?) -> Void) {
+ guard let getInfoFn else {
+ completion(nil)
+ return
+ }
+
+ getInfoFn(DispatchQueue.main) { dict in
+ guard !dict.isEmpty else {
+ completion(nil)
+ return
+ }
+
+ var info = MediaRemoteInfo()
+ info.title = dict[kTitle] as? String ?? ""
+ info.artist = dict[kArtist] as? String ?? ""
+ info.album = dict[kAlbum] as? String ?? ""
+ info.duration = dict[kDuration] as? TimeInterval ?? 0
+ info.elapsedTime = dict[kElapsedTime] as? TimeInterval ?? 0
+ info.playbackRate = dict[kPlaybackRate] as? Double ?? 0
+ info.isPlaying = info.playbackRate > 0
+
+ if let data = dict[kArtworkData] as? Data {
+ info.artwork = NSImage(data: data)
+ }
+
+ // Title empty and no artwork means MediaRemote returned a stale /
+ // blocked payload. Treat as miss.
+ if info.title.isEmpty {
+ completion(nil)
+ } else {
+ completion(info)
+ }
+ }
+ }
+
+ /// Fire and forget control command.
+ func sendCommand(_ cmd: MediaRemoteCommand) {
+ guard let sendCommandFn else { return }
+ _ = sendCommandFn(cmd.rawValue, nil)
+ }
+
+ /// Adjust the playhead position of whatever is currently playing.
+ func setElapsedTime(_ t: TimeInterval) {
+ setElapsedTimeFn?(max(0, t))
+ }
+
+ /// Register for MediaRemote change notifications. The closure is dispatched
+ /// on the main queue so callers can touch UI state directly.
+ func registerForNotifications(onChange: @escaping () -> Void) {
+ registerFn?(DispatchQueue.main)
+
+ let names = [
+ "kMRMediaRemoteNowPlayingInfoDidChangeNotification",
+ "kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification",
+ "kMRMediaRemoteNowPlayingApplicationDidChangeNotification",
+ "kMRMediaRemoteNowPlayingPlaybackQueueChangedNotification"
+ ]
+
+ let center = NotificationCenter.default
+ for raw in names {
+ let token = center.addObserver(
+ forName: NSNotification.Name(raw),
+ object: nil,
+ queue: .main
+ ) { _ in
+ onChange()
+ }
+ notificationObservers.append(token)
+ }
+ }
+}
diff --git a/Sources/sources/SpotifyAppleScript.swift b/Sources/sources/SpotifyAppleScript.swift
new file mode 100644
index 0000000..3f00872
--- /dev/null
+++ b/Sources/sources/SpotifyAppleScript.swift
@@ -0,0 +1,136 @@
+//
+// SpotifyAppleScript.swift
+// MioIsland Music Plugin
+//
+// AppleScript bridge for the Spotify desktop app. Spotify exposes a rich
+// scripting dictionary so we can pull title / artist / album / duration /
+// position and drive transport controls. Artwork URL is also scriptable
+// which makes this cheaper than any other source.
+//
+// Threading: all scripts run on a background queue via runAppleScript.
+// NSAppleScript is NOT safe to share across threads; each call creates a
+// fresh instance. Duration from Spotify is in milliseconds (we divide by
+// 1000 inside the script) and player position is in seconds.
+//
+
+import AppKit
+
+struct AppleScriptTrackInfo {
+ var title: String
+ var artist: String
+ var album: String
+ var duration: TimeInterval
+ var elapsedTime: TimeInterval
+ var isPlaying: Bool
+ var artworkURL: String?
+ var source: String // "Spotify" / "Apple Music"
+ var bundleId: String
+}
+
+enum SpotifyAppleScript {
+ private static let bundleId = "com.spotify.client"
+ private static let sourceName = "Spotify"
+
+ // MARK: - Fetch
+
+ static func fetch() async -> AppleScriptTrackInfo? {
+ let script = """
+ tell application "System Events"
+ if not (exists process "Spotify") then return "NOT_RUNNING"
+ end tell
+ tell application "Spotify"
+ if player state is playing or player state is paused then
+ set trackName to name of current track
+ set trackArtist to artist of current track
+ set trackAlbum to album of current track
+ set trackDuration to duration of current track
+ set trackPosition to player position
+ set stateString to "PAUSED"
+ if player state is playing then set stateString to "PLAYING"
+ set artURL to ""
+ try
+ set artURL to artwork url of current track
+ end try
+ return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & (trackDuration / 1000) & "||" & trackPosition & "||" & artURL
+ else
+ return "NOT_PLAYING"
+ end if
+ end tell
+ """
+
+ guard let raw = await runAppleScript(script, tag: "spotify") else { return nil }
+ if raw == "NOT_RUNNING" || raw == "NOT_PLAYING" { return nil }
+
+ let parts = raw.components(separatedBy: "||")
+ guard parts.count >= 6 else { return nil }
+
+ let isPlaying = parts[0] == "PLAYING"
+ let artURL = parts.count >= 7 ? parts[6] : ""
+
+ return AppleScriptTrackInfo(
+ title: parts[1],
+ artist: parts[2],
+ album: parts[3],
+ duration: TimeInterval(parts[4]) ?? 0,
+ elapsedTime: TimeInterval(parts[5]) ?? 0,
+ isPlaying: isPlaying,
+ artworkURL: artURL.isEmpty ? nil : artURL,
+ source: sourceName,
+ bundleId: bundleId
+ )
+ }
+
+ // MARK: - Artwork
+
+ static func fetchArtwork() async -> NSImage? {
+ let script = """
+ tell application "System Events"
+ if not (exists process "Spotify") then return ""
+ end tell
+ tell application "Spotify"
+ try
+ return artwork url of current track
+ on error
+ return ""
+ end try
+ end tell
+ """
+ guard let urlString = await runAppleScript(script, tag: "spotify-art"),
+ !urlString.isEmpty,
+ let url = URL(string: urlString) else {
+ return nil
+ }
+ return await downloadImage(from: url)
+ }
+
+ // MARK: - Controls
+
+ static func togglePlay() {
+ runAppleScriptFireAndForget(
+ "tell application \"Spotify\" to playpause",
+ tag: "spotify-toggle"
+ )
+ }
+
+ static func next() {
+ runAppleScriptFireAndForget(
+ "tell application \"Spotify\" to next track",
+ tag: "spotify-next"
+ )
+ }
+
+ static func previous() {
+ runAppleScriptFireAndForget(
+ "tell application \"Spotify\" to previous track",
+ tag: "spotify-prev"
+ )
+ }
+
+ static func seek(to time: TimeInterval) {
+ let clamped = max(0, time)
+ runAppleScriptFireAndForget(
+ "tell application \"Spotify\" to set player position to \(clamped)",
+ tag: "spotify-seek"
+ )
+ }
+}
diff --git a/Sources/support/ChineseAppDetector.swift b/Sources/support/ChineseAppDetector.swift
new file mode 100644
index 0000000..898e3cf
--- /dev/null
+++ b/Sources/support/ChineseAppDetector.swift
@@ -0,0 +1,64 @@
+//
+// ChineseAppDetector.swift
+// MioIsland Music Plugin
+//
+// Detects whether a Chinese desktop music app (QQ 音乐 / 网易云音乐 / 酷狗)
+// is currently running. These apps do not publish their Now Playing state
+// to MediaRemote and do not expose a scripting dictionary, so we cannot
+// read tracks from them. When detected, the UI surfaces a polite message
+// telling the user to switch to the web player instead of showing an empty
+// or misleading Now Playing view.
+//
+
+import AppKit
+
+struct ChineseAppDetector {
+ private struct Rule {
+ let bundleIDs: [String]
+ let nameFragments: [String]
+ let displayName: String
+ }
+
+ // Bundle IDs are the primary key; localized name fragments are a fallback
+ // in case the vendor ships a repackaged build with a different bundle ID.
+ private static let rules: [Rule] = [
+ Rule(
+ bundleIDs: ["com.tencent.qqmusicmac", "com.tencent.QQMusicMac"],
+ nameFragments: ["QQ音乐", "QQ 音乐", "QQMusic"],
+ displayName: "QQ 音乐"
+ ),
+ Rule(
+ bundleIDs: ["com.netease.163music", "com.netease.cloudmusicmac"],
+ nameFragments: ["网易云音乐", "网易云", "NeteaseMusic", "CloudMusic"],
+ displayName: "网易云音乐"
+ ),
+ Rule(
+ bundleIDs: ["com.kugou.mac", "com.kugou.KuGouMusic"],
+ nameFragments: ["酷狗", "KuGou"],
+ displayName: "酷狗音乐"
+ )
+ ]
+
+ /// Return the display name of the first matching running app, or nil.
+ static func detectRunning() -> String? {
+ let apps = NSWorkspace.shared.runningApplications
+ for rule in rules {
+ for app in apps {
+ if app.isTerminated { continue }
+
+ if let bid = app.bundleIdentifier,
+ rule.bundleIDs.contains(where: { bid.caseInsensitiveCompare($0) == .orderedSame }) {
+ return rule.displayName
+ }
+
+ if let name = app.localizedName {
+ for frag in rule.nameFragments
+ where name.range(of: frag, options: .caseInsensitive) != nil {
+ return rule.displayName
+ }
+ }
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/support/HostVersionCheck.swift b/Sources/support/HostVersionCheck.swift
new file mode 100644
index 0000000..9314241
--- /dev/null
+++ b/Sources/support/HostVersionCheck.swift
@@ -0,0 +1,92 @@
+//
+// HostVersionCheck.swift
+// MioIsland Music Plugin
+//
+// Determines whether the host app (Mio Island) is new enough to provide
+// NSAppleEventsUsageDescription, which is required for any AppleScript
+// based source (Spotify / Music / Chrome). If the host is too old, the UI
+// surfaces an "upgrade to Mio Island X.Y.Z" banner instead of silently
+// showing no track.
+//
+// We intentionally avoid any dependency on third party version libraries.
+// Semantic version comparison is implemented manually via tuple compare.
+//
+
+import Foundation
+
+struct HostVersionCheck {
+ /// Minimum host version that ships NSAppleEventsUsageDescription.
+ static let minRequired = "2.1.7"
+
+ /// Known Mio Island bundle IDs. Accept all of them so dev / staging /
+ /// white-labelled builds still report as "on host" correctly.
+ /// Real host bundle ID is historical ("Code Island" -> renamed "Mio Island"
+ /// at display layer only; bundle ID kept stable for Sparkle update continuity).
+ private static let hostBundleIDs: Set = [
+ "com.codeisland.app",
+ "com.mioisland.app",
+ "com.mioisland.ClaudeIsland",
+ "com.mio.island",
+ "chat.miomio.island"
+ ]
+
+ /// Read CFBundleShortVersionString from the hosting process. Returns nil
+ /// if Bundle.main has no version key (should not happen in practice).
+ static func hostVersion() -> String? {
+ if let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
+ !v.isEmpty {
+ return v
+ }
+ return nil
+ }
+
+ /// True when the current host process is Mio Island, per bundle ID.
+ static func isMioIslandHost() -> Bool {
+ guard let bid = Bundle.main.bundleIdentifier else { return false }
+ if hostBundleIDs.contains(bid) { return true }
+ // Loose match for renamed development builds or white-labelled forks.
+ return bid.localizedCaseInsensitiveContains("mioisland") ||
+ bid.localizedCaseInsensitiveContains("mio.island") ||
+ bid.localizedCaseInsensitiveContains("codeisland")
+ }
+
+ /// True when host version is ≥ minRequired. Non Mio Island processes
+ /// (e.g. the compile-only linter) pass through as true so we do not
+ /// accidentally block in unrelated hosts.
+ static func isOK() -> Bool {
+ guard isMioIslandHost() else { return true }
+ guard let version = hostVersion() else { return false }
+ return compare(version, minRequired) != .orderedAscending
+ }
+
+ // MARK: - Semantic version compare (handwritten, no third party lib)
+
+ /// Compare two dot-separated numeric version strings. Non-numeric or
+ /// missing components default to 0. Examples:
+ /// compare("2.1.7", "2.1.7") -> .orderedSame
+ /// compare("2.1.6", "2.1.7") -> .orderedAscending
+ /// compare("2.2.0", "2.1.7") -> .orderedDescending
+ /// compare("2.1.7.1","2.1.7") -> .orderedDescending
+ static func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {
+ let l = components(of: lhs)
+ let r = components(of: rhs)
+ let count = max(l.count, r.count)
+ for i in 0.. b { return .orderedDescending }
+ }
+ return .orderedSame
+ }
+
+ private static func components(of version: String) -> [Int] {
+ version
+ .split(separator: ".")
+ .map { part -> Int in
+ // Strip any non-digit suffix e.g. "7-beta" -> 7.
+ let digits = part.prefix { $0.isNumber }
+ return Int(digits) ?? 0
+ }
+ }
+}
diff --git a/Sources/support/Localization.swift b/Sources/support/Localization.swift
new file mode 100644
index 0000000..1048265
--- /dev/null
+++ b/Sources/support/Localization.swift
@@ -0,0 +1,118 @@
+//
+// Localization.swift
+// MusicPlugin
+//
+// Minimal zh/en string map for the Now Playing plugin. Follows the
+// same pattern as StatsPlugin: the host app's `appLanguage`
+// UserDefault is the single source of truth, with an "auto" fallback
+// to system locale.
+//
+
+import Foundation
+
+enum L10n {
+ /// "zh" when the user has selected Chinese (explicitly or via system
+ /// locale fallback), "en" otherwise. Two discrete cases, no third.
+ static var language: String {
+ isChinese ? "zh" : "en"
+ }
+
+ static var isChinese: Bool {
+ let setting = UserDefaults.standard.string(forKey: "appLanguage") ?? "auto"
+ switch setting {
+ case "zh": return true
+ case "en": return false
+ default:
+ if let code = Locale.current.language.languageCode?.identifier,
+ code.hasPrefix("zh") {
+ return true
+ }
+ if let pref = Locale.preferredLanguages.first,
+ pref.hasPrefix("zh") {
+ return true
+ }
+ return false
+ }
+ }
+
+ // MARK: - Empty state
+
+ static var nothingPlaying: String {
+ isChinese ? "暂无播放" : "Nothing playing"
+ }
+
+ static var nothingPlayingHint: String {
+ isChinese
+ ? "在 Spotify、Apple Music 或 Chrome 里播放音乐"
+ : "Play something in Spotify, Apple Music, or Chrome"
+ }
+
+ // MARK: - Host version too old
+
+ static var hostUpgradeTitle: String {
+ isChinese ? "需要 Mio Island v2.1.7+" : "Mio Island v2.1.7+ required"
+ }
+
+ static var hostUpgradeHint: String {
+ isChinese
+ ? "请升级主 app 以启用完整功能"
+ : "Please upgrade Mio Island to unlock full plugin features"
+ }
+
+ // MARK: - Chinese app running detection
+
+ static func chineseAppTitle(_ appName: String) -> String {
+ isChinese ? "检测到 \(appName) 运行" : "\(appName) detected"
+ }
+
+ static var chineseAppHint: String {
+ isChinese
+ ? "桌面端暂不支持曲目抓取,试试打开网页版"
+ : "Desktop version not supported. Try the web version in Chrome."
+ }
+
+ // MARK: - Small bits used around the card
+
+ /// Separator glyph placed between artist and album in compact layouts.
+ static var byArtist: String {
+ isChinese ? "・" : "by"
+ }
+
+ /// Short label used near the playback source badge.
+ static var sourceLabel: String {
+ isChinese ? "来源" : "Source"
+ }
+
+ /// "Now Playing" heading for the expanded card.
+ static var nowPlayingHeading: String {
+ isChinese ? "正在播放" : "Now Playing"
+ }
+
+ /// Accessibility / tooltip labels for transport controls.
+ static var playTooltip: String {
+ isChinese ? "播放" : "Play"
+ }
+
+ static var pauseTooltip: String {
+ isChinese ? "暂停" : "Pause"
+ }
+
+ static var previousTooltip: String {
+ isChinese ? "上一首" : "Previous"
+ }
+
+ static var nextTooltip: String {
+ isChinese ? "下一首" : "Next"
+ }
+
+ /// Fallback strings shown when NowPlayingState has a blank field but
+ /// we still need to render something (e.g. while the first Chrome
+ /// query is in flight).
+ static var unknownTitle: String {
+ isChinese ? "未知曲目" : "Unknown Title"
+ }
+
+ static var unknownArtist: String {
+ isChinese ? "未知艺术家" : "Unknown Artist"
+ }
+}
diff --git a/Sources/ui/AlbumArtColorExtractor.swift b/Sources/ui/AlbumArtColorExtractor.swift
new file mode 100644
index 0000000..8ec0a04
--- /dev/null
+++ b/Sources/ui/AlbumArtColorExtractor.swift
@@ -0,0 +1,134 @@
+//
+// AlbumArtColorExtractor.swift
+// MusicPlugin
+//
+// Pulls an average color out of an NSImage, then boosts saturation
+// and brightness so the resulting tint works as a background gradient
+// behind white text. Runs on a background queue; posts completion back
+// to the main queue.
+//
+// Adapted from the SuperIsland AlbumArtView extension, specialized for
+// our background-gradient use case (we want stronger saturation than
+// a glow layer wants).
+//
+
+import AppKit
+import CoreGraphics
+import SwiftUI
+
+enum AlbumArtColorExtractor {
+ /// Extract a "tint-ready" color from an album art image. Computes
+ /// an average color, skips near-black results (replaces with a soft
+ /// neutral), and boosts saturation/brightness so the color reads
+ /// well as a background gradient. Result is delivered on the main
+ /// queue.
+ static func extract(from image: NSImage?, completion: @escaping (NSColor?) -> Void) {
+ guard let image else {
+ DispatchQueue.main.async { completion(nil) }
+ return
+ }
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ let color = averageColor(of: image).map { boost($0) }
+ DispatchQueue.main.async { completion(color) }
+ }
+ }
+
+ /// Produce a two-stop LinearGradient for the expanded view
+ /// background. Fallback is a plain near-black gradient when no
+ /// color is available.
+ static func backgroundGradient(for color: NSColor?) -> LinearGradient {
+ guard let color else {
+ return LinearGradient(
+ colors: [
+ Color(red: 0x0A / 255.0, green: 0x0A / 255.0, blue: 0x0A / 255.0),
+ Color(red: 0x05 / 255.0, green: 0x05 / 255.0, blue: 0x05 / 255.0)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ // Top: the boosted tint at ~35% opacity over a near-black base.
+ // Bottom: fades back to near-black so text remains readable.
+ let top = Color(nsColor: color).opacity(0.35)
+ let bottom = Color(red: 0x0A / 255.0, green: 0x0A / 255.0, blue: 0x0A / 255.0)
+ return LinearGradient(
+ colors: [top, bottom],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+
+ // MARK: - Internals
+
+ private static func averageColor(of image: NSImage) -> NSColor? {
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return nil
+ }
+
+ // Downscale to a small thumbnail before averaging. Avoids scanning
+ // millions of pixels for a 2000x2000 album art.
+ let targetSide = 40
+ let width = targetSide
+ let height = targetSide
+ let totalPixels = width * height
+ guard totalPixels > 0,
+ let context = CGContext(
+ data: nil,
+ width: width,
+ height: height,
+ bitsPerComponent: 8,
+ bytesPerRow: width * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else {
+ return nil
+ }
+
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
+ guard let data = context.data else { return nil }
+
+ let pointer = data.bindMemory(to: UInt32.self, capacity: totalPixels)
+ var totalRed: UInt64 = 0
+ var totalGreen: UInt64 = 0
+ var totalBlue: UInt64 = 0
+
+ for index in 0..> 8) & 0xFF)
+ totalBlue += UInt64((color >> 16) & 0xFF)
+ }
+
+ let r = CGFloat(totalRed) / CGFloat(totalPixels) / 255.0
+ let g = CGFloat(totalGreen) / CGFloat(totalPixels) / 255.0
+ let b = CGFloat(totalBlue) / CGFloat(totalPixels) / 255.0
+
+ // Near-black album art (cover is mostly black). Return a soft
+ // neutral so the background doesn't collapse to pure black with
+ // zero tint.
+ if r < 0.04, g < 0.04, b < 0.04 {
+ return NSColor(red: 0.35, green: 0.35, blue: 0.38, alpha: 1.0)
+ }
+ return NSColor(red: r, green: g, blue: b, alpha: 1.0)
+ }
+
+ /// Push the HSB up so we get a saturated, bright tint suitable for
+ /// a background gradient. Clamps so extremely neon sources don't
+ /// blow out.
+ private static func boost(_ color: NSColor) -> NSColor {
+ let rgb = color.usingColorSpace(.sRGB) ?? color
+ var hue: CGFloat = 0
+ var saturation: CGFloat = 0
+ var brightness: CGFloat = 0
+ var alpha: CGFloat = 0
+ rgb.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
+
+ return NSColor(
+ hue: hue,
+ saturation: min(max(saturation * 1.35, 0.55), 0.95),
+ brightness: min(max(brightness * 1.25, 0.65), 0.92),
+ alpha: alpha
+ )
+ }
+}
diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift
new file mode 100644
index 0000000..b8ad392
--- /dev/null
+++ b/Sources/ui/ExpandedView.swift
@@ -0,0 +1,376 @@
+//
+// ExpandedView.swift
+// MusicPlugin
+//
+// Main panel view, sized roughly 620x780 by the host. Four states
+// rendered in priority order:
+// 1. Host version too old (hostVersionOK == false)
+// 2. Chinese desktop app running (chineseAppDetected != nil)
+// 3. Nothing playing (title.isEmpty)
+// 4. Now playing (default)
+//
+// Background uses an extracted tint from the album art (fades to
+// near-black). Control surface, text and spacing follow the
+// MioIsland aesthetic:
+// - #0A0A0A near-black base
+// - white text with opacity tiers (1.0 / 0.7 / 0.5 / 0.3)
+// - lime #CAFF00 as the single accent color
+// - 16pt corner on the big card, 8pt on small chips
+//
+
+import AppKit
+import SwiftUI
+
+struct ExpandedView: View {
+ @ObservedObject private var state = NowPlayingState.shared
+
+ /// Tint extracted from the current album art. Updated via
+ /// AlbumArtColorExtractor whenever the art changes.
+ @State private var tintColor: NSColor?
+
+ private static let lime = Color(
+ red: 0xCA / 255.0,
+ green: 0xFF / 255.0,
+ blue: 0x00 / 255.0
+ )
+ private static let ink = Color.white
+ private static let base = Color(red: 0x0A / 255.0, green: 0x0A / 255.0, blue: 0x0A / 255.0)
+
+ // MARK: - Body
+
+ var body: some View {
+ ZStack {
+ AlbumArtColorExtractor
+ .backgroundGradient(for: tintColor)
+ .ignoresSafeArea()
+
+ content
+ .padding(28)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Self.base)
+ .onAppear { refreshTint(for: state.albumArt) }
+ .onChange(of: state.albumArt?.tiffRepresentation) { _, _ in
+ refreshTint(for: state.albumArt)
+ }
+ .animation(.easeInOut(duration: 0.25), value: currentMode)
+ }
+
+ // MARK: - State routing
+
+ private enum Mode: Equatable {
+ case hostTooOld
+ case chineseAppWarning(String)
+ case empty
+ case playing
+ }
+
+ private var currentMode: Mode {
+ if !state.hostVersionOK { return .hostTooOld }
+ if let name = state.chineseAppDetected, !name.isEmpty {
+ return .chineseAppWarning(name)
+ }
+ if state.title.isEmpty { return .empty }
+ return .playing
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ switch currentMode {
+ case .hostTooOld:
+ warningCard(
+ symbol: "exclamationmark.triangle.fill",
+ title: L10n.hostUpgradeTitle,
+ hint: L10n.hostUpgradeHint,
+ tint: .orange
+ )
+ case .chineseAppWarning(let appName):
+ warningCard(
+ symbol: "exclamationmark.circle.fill",
+ title: L10n.chineseAppTitle(appName),
+ hint: L10n.chineseAppHint,
+ tint: .yellow
+ )
+ case .empty:
+ emptyCard
+ case .playing:
+ playingCard
+ }
+ }
+
+ // MARK: - Playing card
+
+ private var playingCard: some View {
+ VStack(spacing: 0) {
+ // Header row: small eyebrow + source badge.
+ HStack(alignment: .firstTextBaseline) {
+ Text(L10n.nowPlayingHeading.uppercased())
+ .font(.system(size: 10, weight: .bold))
+ .tracking(2)
+ .foregroundColor(Self.ink.opacity(0.5))
+ Spacer()
+ sourceBadge
+ }
+ .padding(.bottom, 22)
+
+ // Album art (big, centered)
+ albumArt
+ .padding(.bottom, 24)
+
+ // Title + artist + album
+ VStack(spacing: 8) {
+ Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundColor(Self.ink)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
+ .font(.system(size: 14, weight: .regular))
+ .foregroundColor(Self.ink.opacity(0.75))
+ .lineLimit(1)
+
+ if !state.album.isEmpty {
+ Text(state.album)
+ .font(.system(size: 12, weight: .regular))
+ .foregroundColor(Self.ink.opacity(0.45))
+ .lineLimit(1)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.bottom, 28)
+
+ // Seek bar + time labels
+ VStack(spacing: 6) {
+ SeekBar(
+ progress: state.progress,
+ duration: state.duration
+ ) { newTime in
+ state.seek(to: newTime)
+ }
+
+ HStack {
+ Text(state.formattedElapsed)
+ .font(.system(size: 10, weight: .regular, design: .monospaced))
+ .foregroundColor(Self.ink.opacity(0.5))
+ Spacer()
+ Text(state.formattedDuration)
+ .font(.system(size: 10, weight: .regular, design: .monospaced))
+ .foregroundColor(Self.ink.opacity(0.5))
+ }
+ }
+ .padding(.bottom, 24)
+
+ // Transport controls
+ transportControls
+ }
+ .frame(maxWidth: 520)
+ }
+
+ private var albumArt: some View {
+ ZStack {
+ if let art = state.albumArt {
+ Image(nsImage: art)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 260, height: 260)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ } else {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.white.opacity(0.08))
+ .frame(width: 260, height: 260)
+ .overlay(
+ Image(systemName: "music.note")
+ .font(.system(size: 64, weight: .light))
+ .foregroundColor(Self.ink.opacity(0.35))
+ )
+ }
+ }
+ .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
+ }
+
+ private var sourceBadge: some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4))
+ .frame(width: 6, height: 6)
+ Text(displaySourceName)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(Self.ink.opacity(0.7))
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(
+ Capsule().fill(Color.white.opacity(0.08))
+ )
+ }
+
+ private var displaySourceName: String {
+ state.sourceName.isEmpty ? "..." : state.sourceName
+ }
+
+ private var transportControls: some View {
+ HStack(spacing: 40) {
+ transportButton(
+ symbol: "backward.fill",
+ size: 20,
+ tooltip: L10n.previousTooltip
+ ) {
+ state.previousTrack()
+ }
+
+ // Play / pause. Larger, accent button.
+ Button(action: { state.togglePlayPause() }) {
+ ZStack {
+ Circle()
+ .fill(Self.lime)
+ .frame(width: 56, height: 56)
+ Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
+ .font(.system(size: 22, weight: .bold))
+ .foregroundColor(.black)
+ .offset(x: state.isPlaying ? 0 : 2) // optical nudge for play
+ }
+ }
+ .buttonStyle(.plain)
+ .help(state.isPlaying ? L10n.pauseTooltip : L10n.playTooltip)
+ .animation(.spring(response: 0.3, dampingFraction: 0.7), value: state.isPlaying)
+
+ transportButton(
+ symbol: "forward.fill",
+ size: 20,
+ tooltip: L10n.nextTooltip
+ ) {
+ state.nextTrack()
+ }
+ }
+ }
+
+ private func transportButton(
+ symbol: String,
+ size: CGFloat,
+ tooltip: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ TransportIconButton(
+ symbol: symbol,
+ size: size,
+ tooltip: tooltip,
+ action: action
+ )
+ }
+
+ // MARK: - Empty card (nothing playing)
+
+ private var emptyCard: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "music.note")
+ .font(.system(size: 44, weight: .light))
+ .foregroundColor(Self.ink.opacity(0.3))
+
+ Text(L10n.nothingPlaying)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(Self.ink.opacity(0.7))
+
+ Text(L10n.nothingPlayingHint)
+ .font(.system(size: 12, weight: .regular))
+ .foregroundColor(Self.ink.opacity(0.4))
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(32)
+ .frame(maxWidth: 360)
+ }
+
+ // MARK: - Warning cards (host outdated / chinese app detected)
+
+ private func warningCard(
+ symbol: String,
+ title: String,
+ hint: String,
+ tint: Color
+ ) -> some View {
+ VStack(spacing: 14) {
+ Image(systemName: symbol)
+ .font(.system(size: 40, weight: .regular))
+ .foregroundColor(tint)
+
+ Text(title)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(Self.ink.opacity(0.9))
+ .multilineTextAlignment(.center)
+
+ Text(hint)
+ .font(.system(size: 12, weight: .regular))
+ .foregroundColor(Self.ink.opacity(0.55))
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineSpacing(2)
+ }
+ .padding(28)
+ .frame(maxWidth: 380)
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.white.opacity(0.04))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .stroke(Color.white.opacity(0.08), lineWidth: 0.5)
+ )
+ }
+
+ // MARK: - Tint refresh
+
+ private func refreshTint(for image: NSImage?) {
+ // Prefer the tint NowPlayingState already computed (if data source
+ // pushed one), but fall back to extracting here. Either way, we
+ // re-run extraction so the gradient tracks the current art.
+ if let stateColor = state.albumArtColor {
+ tintColor = stateColor
+ return
+ }
+ AlbumArtColorExtractor.extract(from: image) { color in
+ tintColor = color
+ }
+ }
+}
+
+// MARK: - Transport icon button
+
+/// Ghost-style round icon button with a lime hover glow. Factored out
+/// so it can own its own @State for hover without mutating parent.
+private struct TransportIconButton: View {
+ let symbol: String
+ let size: CGFloat
+ let tooltip: String
+ let action: () -> Void
+
+ @State private var isHovered = false
+
+ private static let lime = Color(
+ red: 0xCA / 255.0,
+ green: 0xFF / 255.0,
+ blue: 0x00 / 255.0
+ )
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: symbol)
+ .font(.system(size: size, weight: .semibold))
+ .foregroundColor(isHovered ? Self.lime : Color.white.opacity(0.75))
+ .frame(width: 44, height: 44)
+ .background(
+ Circle()
+ .fill(Color.white.opacity(isHovered ? 0.10 : 0.0))
+ )
+ .scaleEffect(isHovered ? 1.05 : 1.0)
+ .animation(.easeInOut(duration: 0.15), value: isHovered)
+ }
+ .buttonStyle(.plain)
+ .help(tooltip)
+ .onHover { hovering in
+ isHovered = hovering
+ }
+ }
+}
diff --git a/Sources/ui/HeaderSlotView.swift b/Sources/ui/HeaderSlotView.swift
new file mode 100644
index 0000000..f362cdf
--- /dev/null
+++ b/Sources/ui/HeaderSlotView.swift
@@ -0,0 +1,121 @@
+//
+// HeaderSlotView.swift
+// MusicPlugin
+//
+// Tiny 20x20 icon that lives in the notch header slot. Shows:
+// - A music.note SF symbol tinted by play state.
+// - 3 thin "pseudo-spectrum" bars breathing next to it when playing.
+// - 1.15x hover scale + pointing-hand cursor.
+// Tap posts .openPlugin (userInfo = ["pluginId": "music-player"]) so
+// the host app knows to slide the music plugin view into focus.
+//
+
+import AppKit
+import SwiftUI
+
+/// Notification the host listens for to switch the notch panel to a
+/// specific plugin. Matches the existing openPlugin contract used by
+/// other plugins.
+extension Notification.Name {
+ static let openPlugin = Notification.Name("com.codeisland.openPlugin")
+}
+
+struct HeaderSlotView: View {
+ @ObservedObject private var state = NowPlayingState.shared
+ @State private var isHovered = false
+
+ private static let lime = Color(
+ red: 0xCA / 255.0,
+ green: 0xFF / 255.0,
+ blue: 0x00 / 255.0
+ )
+
+ var body: some View {
+ Button(action: openPluginPanel) {
+ HStack(spacing: 2) {
+ Image(systemName: "music.note")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundColor(iconColor)
+
+ // Pseudo spectrum: 3 tiny bars to the right of the note
+ if shouldShowBars {
+ PseudoSpectrumBars(isPlaying: state.isPlaying, tint: iconColor)
+ .frame(width: 7, height: 10)
+ }
+ }
+ .frame(width: 20, height: 20)
+ .contentShape(Rectangle())
+ .scaleEffect(isHovered ? 1.15 : 1.0)
+ .animation(.easeInOut(duration: 0.15), value: isHovered)
+ .animation(.easeInOut(duration: 0.2), value: state.isPlaying)
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ isHovered = hovering
+ if hovering {
+ NSCursor.pointingHand.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ .frame(width: 20, height: 20)
+ .fixedSize()
+ }
+
+ private var shouldShowBars: Bool {
+ // Hide bars entirely in "idle" (no track at all), feels cleaner.
+ // Show them (static) in paused, (breathing) in playing.
+ !state.title.isEmpty
+ }
+
+ private var iconColor: Color {
+ if state.isPlaying { return Self.lime }
+ if !state.title.isEmpty { return Color.white.opacity(0.5) }
+ return Color.white.opacity(0.25)
+ }
+
+ private func openPluginPanel() {
+ NotificationCenter.default.post(
+ name: .openPlugin,
+ object: nil,
+ userInfo: ["pluginId": "music-player"]
+ )
+ }
+}
+
+// MARK: - Pseudo spectrum (3 bars)
+
+/// 3 tiny vertical bars that "breathe" when music is playing. Heights
+/// are driven by sin() against a TimelineView clock so we animate
+/// smoothly without an NSTimer.
+private struct PseudoSpectrumBars: View {
+ let isPlaying: Bool
+ let tint: Color
+
+ var body: some View {
+ TimelineView(.animation(minimumInterval: 0.15, paused: !isPlaying)) { context in
+ let time = context.date.timeIntervalSinceReferenceDate
+ HStack(alignment: .center, spacing: 1.5) {
+ ForEach(0..<3, id: \.self) { i in
+ RoundedRectangle(cornerRadius: 0.75)
+ .fill(tint)
+ .frame(width: 1.5, height: barHeight(index: i, time: time))
+ }
+ }
+ }
+ }
+
+ /// Height in 2...8pt. Each bar gets a distinct phase so they
+ /// don't rise in sync. When paused we hold a quiet mid-height.
+ private func barHeight(index: Int, time: TimeInterval) -> CGFloat {
+ guard isPlaying else { return 4 }
+ // Each bar has its own frequency + phase offset so they feel
+ // independent. Frequencies chosen by ear to look "alive" but
+ // not frantic at 0.15s updates.
+ let freq = [2.1, 3.3, 2.7][index]
+ let phase = [0.0, 1.1, 2.4][index]
+ let raw = sin(time * freq + phase) // -1...1
+ let normalized = (raw + 1) * 0.5 // 0...1
+ return 2 + CGFloat(normalized) * 6 // 2...8
+ }
+}
diff --git a/Sources/ui/SeekBar.swift b/Sources/ui/SeekBar.swift
new file mode 100644
index 0000000..2fcd779
--- /dev/null
+++ b/Sources/ui/SeekBar.swift
@@ -0,0 +1,94 @@
+//
+// SeekBar.swift
+// MusicPlugin
+//
+// Draggable progress bar used at the bottom of the expanded card.
+// Visual rules:
+// - 4pt tall normally, grows to 6pt on hover.
+// - Background track white @ 10%.
+// - Filled portion lime #CAFF00.
+// - 12x12 white knob shows only on hover or during drag.
+// - Dragging updates a local preview; onEnded commits via seek(to:).
+//
+
+import SwiftUI
+
+struct SeekBar: View {
+ /// Current progress in 0...1 driven by NowPlayingState.
+ let progress: Double
+ /// Track total duration (seconds), needed to compute the final
+ /// seek target when the drag ends.
+ let duration: TimeInterval
+ /// Called with an absolute time (seconds) when the user finishes
+ /// dragging. Not called mid-drag.
+ let onSeek: (TimeInterval) -> Void
+
+ // Lime brand accent.
+ private static let lime = Color(
+ red: 0xCA / 255.0,
+ green: 0xFF / 255.0,
+ blue: 0x00 / 255.0
+ )
+
+ @State private var dragProgress: Double?
+ @State private var isHovering = false
+
+ var body: some View {
+ GeometryReader { geometry in
+ let displayed = min(max(dragProgress ?? progress, 0), 1)
+ let trackHeight: CGFloat = (isHovering || dragProgress != nil) ? 6 : 4
+ let knobSize: CGFloat = 12
+ let knobCenterX = CGFloat(displayed) * geometry.size.width
+ let knobVisible = isHovering || dragProgress != nil
+
+ ZStack(alignment: .leading) {
+ // Background track
+ RoundedRectangle(cornerRadius: trackHeight / 2)
+ .fill(Color.white.opacity(0.10))
+ .frame(height: trackHeight)
+
+ // Filled portion
+ RoundedRectangle(cornerRadius: trackHeight / 2)
+ .fill(Self.lime)
+ .frame(
+ width: max(0, geometry.size.width * CGFloat(displayed)),
+ height: trackHeight
+ )
+ .animation(.easeInOut(duration: 0.2), value: displayed)
+
+ // Knob
+ Circle()
+ .fill(Color.white)
+ .frame(width: knobSize, height: knobSize)
+ .shadow(color: .black.opacity(0.25), radius: 2, y: 1)
+ .offset(x: max(0, knobCenterX - knobSize / 2))
+ .opacity(knobVisible ? 1 : 0)
+ .animation(.easeOut(duration: 0.15), value: knobVisible)
+ }
+ .frame(height: 16, alignment: .center)
+ .contentShape(Rectangle())
+ .onHover { hovering in
+ isHovering = hovering
+ }
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ guard geometry.size.width > 0 else { return }
+ let next = min(max(value.location.x / geometry.size.width, 0), 1)
+ dragProgress = next
+ }
+ .onEnded { value in
+ guard geometry.size.width > 0, duration > 0 else {
+ dragProgress = nil
+ return
+ }
+ let next = min(max(value.location.x / geometry.size.width, 0), 1)
+ dragProgress = nil
+ onSeek(duration * next)
+ }
+ )
+ .animation(.easeInOut(duration: 0.2), value: trackHeight)
+ }
+ .frame(height: 16)
+ }
+}
diff --git a/build.sh b/build.sh
index 162af78..23431df 100755
--- a/build.sh
+++ b/build.sh
@@ -5,9 +5,13 @@ set -e
PLUGIN_NAME="music-player"
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
BUILD_DIR="build"
-SOURCES="Sources/*.swift"
+
+# Recursively pick up every .swift under Sources/ (root + subdirectories
+# like sources/, ui/, support/ for the v2.0.0 layered layout).
+SOURCES=$(find Sources -name "*.swift" -type f)
echo "Building ${PLUGIN_NAME} plugin..."
+echo "Compiling $(echo "$SOURCES" | wc -l | tr -d ' ') Swift files..."
# Clean
rm -rf "${BUILD_DIR}"