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>
This commit is contained in:
徐翔宇 2026-04-19 02:27:21 +08:00
parent 68ca936944
commit 64daaa3371
19 changed files with 2379 additions and 352 deletions

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.0</string> <string>2.0.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>2</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string> <string>MusicPlugin.MusicPlugin</string>
</dict> </dict>

181
README.md
View File

@ -1,76 +1,175 @@
# Music Player Plugin for MioIsland # 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 ## Features
- Displays the currently playing track, artist, and album from any macOS music app (Apple Music, Spotify, etc.) - **Multi-source playback tracking** with sticky source priority:
- Reads system NowPlaying metadata — no need to configure individual apps - MediaRemote (private framework, any app that registers with `MPNowPlayingInfoCenter`)
- Lightweight native `.bundle` plugin with minimal resource usage - Spotify desktop (AppleScript)
- Smooth animated UI that matches the MioIsland design language - Apple Music desktop (AppleScript)
- Appears as a header icon button in the Notch bar - Google Chrome tabs (JavaScript injection into `<video>` / `<audio>` 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 ## Installation
### From MioIsland Plugin Store ### Prerequisite: MioIsland host v2.1.7 or newer
1. Visit [miomio.chat](https://miomio.chat) This plugin uses AppleScript to talk to Spotify / Apple Music / Chrome.
2. Find "Music Player" and click Install macOS requires the host app's `Info.plist` to declare `NSAppleEventsUsageDescription`
3. MioIsland will automatically download and activate the plugin for that. MioIsland added this key in **v2.1.7** — older hosts will show an
upgrade banner inside the plugin and skip AppleScript sources.
### Manual Installation
Upgrade the host:
```bash ```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: Requirements:
- macOS 15.0+ - macOS 15.0+
- Xcode Command Line Tools - Xcode 16+ Command Line Tools
- Swift 5.9+ - Swift 5.10+
```bash ```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 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: Install the build output:
- `build/music-player.bundle` — the plugin (copy to `~/.config/codeisland/plugins/`) ```bash
- `build/music-player.zip` — compressed bundle for marketplace upload cp -R build/music-player.bundle ~/.config/codeisland/plugins/
```
## Plugin Architecture Restart MioIsland.
| File | Purpose | ## Plugin architecture (v2.0.0)
|------|---------|
| `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 |
## 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 <video> / <audio>
├── 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 ## Privacy
- Spotify
- YouTube (in browser) The plugin reads only:
- VLC - System-level Now Playing metadata (MediaRemote)
- Any app using MPNowPlayingInfoCenter - 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 ## License
MIT MIT. See LICENSE.
## Author ## Author
[@xmqywx](https://github.com/xmqywx) [@xmqywx](https://github.com/xmqywx) — part of the [MioMioOS](https://github.com/MioMioOS)
official plugin set.

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -2,40 +2,65 @@
// MusicPlugin.swift // MusicPlugin.swift
// MioIsland Music Plugin // MioIsland Music Plugin
// //
// Principal class for the music-player.bundle plugin. // Principal class for the music-player.bundle plugin (v2.0.0).
// Shows system Now Playing info (Spotify, Apple Music, etc.) //
// with playback controls in the notch. // 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 AppKit
import SwiftUI import SwiftUI
/// Principal class. Module is `MusicPlugin`, class is `MusicPlugin`, so
/// Info.plist NSPrincipalClass = "MusicPlugin.MusicPlugin".
final class MusicPlugin: NSObject, MioPlugin { final class MusicPlugin: NSObject, MioPlugin {
var id: String { "music-player" } var id: String { "music-player" }
var name: String { "Music Player" } var name: String { "Music Player" }
var icon: String { "music.note" } var icon: String { "music.note" }
var version: String { "1.0.0" } var version: String { "2.0.0" }
func activate() { func activate() {
NSLog("[mio-plugin-music] activate")
Task { @MainActor in Task { @MainActor in
NowPlayingBridge.shared.start() NowPlayingState.shared.start()
} }
} }
func deactivate() { func deactivate() {
NSLog("[mio-plugin-music] deactivate")
Task { @MainActor in Task { @MainActor in
NowPlayingBridge.shared.stop() NowPlayingState.shared.stop()
} }
} }
func makeView() -> NSView { 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 { switch slot {
case "header": case "header":
let view = NSHostingView(rootView: MusicHeaderButtonView()) let view = NSHostingView(rootView: HeaderSlotView())
view.frame = NSRect(x: 0, y: 0, width: 20, height: 20) view.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
view.setFrameSize(NSSize(width: 20, height: 20)) view.setFrameSize(NSSize(width: 20, height: 20))
return view return view

View File

@ -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
}
}

View File

@ -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<AnyCancellable>()
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 ?? "<no message>"
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 ?? "<no message>"
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()
}
}

View File

@ -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"
)
}
}

View File

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

View File

@ -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)
}
}
}

View File

@ -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"
)
}
}

View File

@ -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
}
}

View File

@ -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<String> = [
"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..<count {
let a = i < l.count ? l[i] : 0
let b = i < r.count ? r[i] : 0
if a < b { return .orderedAscending }
if a > 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
}
}
}

View File

@ -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"
}
}

View File

@ -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..<totalPixels {
let color = pointer[index]
totalRed += UInt64(color & 0xFF)
totalGreen += UInt64((color >> 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
)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

94
Sources/ui/SeekBar.swift Normal file
View File

@ -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)
}
}

View File

@ -5,9 +5,13 @@ set -e
PLUGIN_NAME="music-player" PLUGIN_NAME="music-player"
BUNDLE_NAME="${PLUGIN_NAME}.bundle" BUNDLE_NAME="${PLUGIN_NAME}.bundle"
BUILD_DIR="build" 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 "Building ${PLUGIN_NAME} plugin..."
echo "Compiling $(echo "$SOURCES" | wc -l | tr -d ' ') Swift files..."
# Clean # Clean
rm -rf "${BUILD_DIR}" rm -rf "${BUILD_DIR}"