mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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:
parent
68ca936944
commit
64daaa3371
@ -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
181
README.md
@ -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.
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
475
Sources/NowPlayingState.swift
Normal file
475
Sources/NowPlayingState.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Sources/sources/AppleMusicAppleScript.swift
Normal file
90
Sources/sources/AppleMusicAppleScript.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
305
Sources/sources/ChromeWebSource.swift
Normal file
305
Sources/sources/ChromeWebSource.swift
Normal 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: "\\\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
193
Sources/sources/MediaRemoteSource.swift
Normal file
193
Sources/sources/MediaRemoteSource.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
Sources/sources/SpotifyAppleScript.swift
Normal file
136
Sources/sources/SpotifyAppleScript.swift
Normal 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Sources/support/ChineseAppDetector.swift
Normal file
64
Sources/support/ChineseAppDetector.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
Sources/support/HostVersionCheck.swift
Normal file
92
Sources/support/HostVersionCheck.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
Sources/support/Localization.swift
Normal file
118
Sources/support/Localization.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Sources/ui/AlbumArtColorExtractor.swift
Normal file
134
Sources/ui/AlbumArtColorExtractor.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
376
Sources/ui/ExpandedView.swift
Normal file
376
Sources/ui/ExpandedView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
Sources/ui/HeaderSlotView.swift
Normal file
121
Sources/ui/HeaderSlotView.swift
Normal 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
94
Sources/ui/SeekBar.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.sh
6
build.sh
@ -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}"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user