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>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>2.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>2</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>MusicPlugin.MusicPlugin</string>
|
||||
</dict>
|
||||
|
||||
181
README.md
181
README.md
@ -1,76 +1,175 @@
|
||||
# Music Player Plugin for MioIsland
|
||||
|
||||
A native plugin that brings real-time music playback information to your MioIsland Notch bar. See what's currently playing across any music app on your Mac without leaving your workflow.
|
||||
> **v2.0.0 — full rewrite.** Real Now Playing info from Spotify, Apple Music,
|
||||
> Google Chrome (YouTube / SoundCloud / 网页版音乐), with playback controls,
|
||||
> draggable seek bar, album art color tint, and a pseudo-spectrum in the header
|
||||
> icon. Replaces the v1.0.0 shell that only wired up MediaRemote.
|
||||
|
||||
## Features
|
||||
|
||||
- Displays the currently playing track, artist, and album from any macOS music app (Apple Music, Spotify, etc.)
|
||||
- Reads system NowPlaying metadata — no need to configure individual apps
|
||||
- Lightweight native `.bundle` plugin with minimal resource usage
|
||||
- Smooth animated UI that matches the MioIsland design language
|
||||
- Appears as a header icon button in the Notch bar
|
||||
- **Multi-source playback tracking** with sticky source priority:
|
||||
- MediaRemote (private framework, any app that registers with `MPNowPlayingInfoCenter`)
|
||||
- Spotify desktop (AppleScript)
|
||||
- Apple Music desktop (AppleScript)
|
||||
- Google Chrome tabs (JavaScript injection into `<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
|
||||
|
||||
### From MioIsland Plugin Store
|
||||
### Prerequisite: MioIsland host v2.1.7 or newer
|
||||
|
||||
1. Visit [miomio.chat](https://miomio.chat)
|
||||
2. Find "Music Player" and click Install
|
||||
3. MioIsland will automatically download and activate the plugin
|
||||
|
||||
### Manual Installation
|
||||
This plugin uses AppleScript to talk to Spotify / Apple Music / Chrome.
|
||||
macOS requires the host app's `Info.plist` to declare `NSAppleEventsUsageDescription`
|
||||
for that. MioIsland added this key in **v2.1.7** — older hosts will show an
|
||||
upgrade banner inside the plugin and skip AppleScript sources.
|
||||
|
||||
Upgrade the host:
|
||||
```bash
|
||||
cp -r music-player.bundle ~/.config/codeisland/plugins/
|
||||
brew upgrade codeisland
|
||||
```
|
||||
|
||||
Restart MioIsland to load the plugin.
|
||||
Or via MioIsland's in-app update (Sparkle will prompt automatically).
|
||||
|
||||
## Building from Source
|
||||
### From MioIsland Plugin Store (recommended)
|
||||
|
||||
1. Open **MioIsland Settings → Plugins**
|
||||
2. Click **打开插件市场 (Open Plugin Store)** — opens https://miomio.chat
|
||||
3. Find **Music Player v2.0.0** and click **Install**
|
||||
4. Copy the generated `https://api.miomio.chat/api/i/...` URL
|
||||
5. Paste into the **Install from URL** field and click **Install**
|
||||
6. Restart MioIsland (menu bar → quit → relaunch)
|
||||
|
||||
### Manual installation
|
||||
|
||||
```bash
|
||||
# Download the latest release from GitHub
|
||||
curl -LO https://github.com/MioMioOS/mio-plugin-music/releases/latest/download/music-player.zip
|
||||
unzip music-player.zip
|
||||
mkdir -p ~/.config/codeisland/plugins/
|
||||
cp -R music-player.bundle ~/.config/codeisland/plugins/
|
||||
# Restart MioIsland
|
||||
```
|
||||
|
||||
### First-run permissions
|
||||
|
||||
When the plugin fetches track info for the first time, macOS will prompt:
|
||||
|
||||
> "Mio Island" would like to control "Spotify".app.
|
||||
|
||||
Click **OK**. Do the same for **Music.app** and **Google Chrome** when they
|
||||
come up. These permissions are granted to the host app once and remembered
|
||||
forever — subsequent launches don't re-prompt.
|
||||
|
||||
If you accidentally clicked **Don't Allow**, fix it in:
|
||||
**System Settings → Privacy & Security → Automation** → toggle Mio Island on
|
||||
for each target app.
|
||||
|
||||
### Chrome-specific setup
|
||||
|
||||
To get Chrome playback (YouTube, SoundCloud, etc.) working:
|
||||
|
||||
1. Open Chrome
|
||||
2. Menu bar: **View → Developer → Allow JavaScript from Apple Events**
|
||||
3. Check the option (click if unchecked)
|
||||
|
||||
This is a one-time setting that lives in Chrome's preferences. Without it,
|
||||
AppleScript JS injection will silently return nothing for Chrome tabs.
|
||||
|
||||
## Building from source
|
||||
|
||||
Requirements:
|
||||
- macOS 15.0+
|
||||
- Xcode Command Line Tools
|
||||
- Swift 5.9+
|
||||
- Xcode 16+ Command Line Tools
|
||||
- Swift 5.10+
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xmqywx/mio-plugin-music.git
|
||||
git clone https://github.com/MioMioOS/mio-plugin-music.git
|
||||
cd mio-plugin-music
|
||||
bash build.sh
|
||||
./build.sh # → build/music-player.bundle + build/music-player.zip
|
||||
./build.sh install # (not implemented — copy manually)
|
||||
```
|
||||
|
||||
The build script outputs:
|
||||
- `build/music-player.bundle` — the plugin (copy to `~/.config/codeisland/plugins/`)
|
||||
- `build/music-player.zip` — compressed bundle for marketplace upload
|
||||
Install the build output:
|
||||
```bash
|
||||
cp -R build/music-player.bundle ~/.config/codeisland/plugins/
|
||||
```
|
||||
|
||||
## Plugin Architecture
|
||||
Restart MioIsland.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MioPlugin.swift` | Plugin protocol definition |
|
||||
| `MusicPlugin.swift` | Main plugin entry point — activate, deactivate, makeView |
|
||||
| `MusicPlayerView.swift` | SwiftUI view displaying track info |
|
||||
| `MusicHeaderButton.swift` | Header icon button for the Notch bar |
|
||||
| `NowPlayingBridge.swift` | Bridges macOS NowPlaying system APIs |
|
||||
## Plugin architecture (v2.0.0)
|
||||
|
||||
## How It Works
|
||||
```
|
||||
Sources/
|
||||
├── MioPlugin.swift — plugin SDK protocol (DO NOT MODIFY)
|
||||
├── MusicPlugin.swift — principal class (activate / makeView / header slot)
|
||||
├── NowPlayingState.swift — @MainActor ObservableObject, source router
|
||||
├── sources/
|
||||
│ ├── MediaRemoteSource.swift — dlopen private MediaRemote.framework
|
||||
│ ├── SpotifyAppleScript.swift
|
||||
│ ├── AppleMusicAppleScript.swift
|
||||
│ └── ChromeWebSource.swift — JS injection into <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
|
||||
- Spotify
|
||||
- YouTube (in browser)
|
||||
- VLC
|
||||
- Any app using MPNowPlayingInfoCenter
|
||||
## Privacy
|
||||
|
||||
The plugin reads only:
|
||||
- System-level Now Playing metadata (MediaRemote)
|
||||
- Current track info from Spotify / Music / Chrome via AppleScript
|
||||
- Bundle IDs of running apps to detect which source is active
|
||||
|
||||
It does **not** read:
|
||||
- Your listening history
|
||||
- Anything outside the "currently playing" state
|
||||
- Anything from apps that are not music-related
|
||||
|
||||
Nothing is sent to any server. All processing happens locally.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT. See LICENSE.
|
||||
|
||||
## Author
|
||||
|
||||
[@xmqywx](https://github.com/xmqywx)
|
||||
[@xmqywx](https://github.com/xmqywx) — part of the [MioMioOS](https://github.com/MioMioOS)
|
||||
official plugin set.
|
||||
|
||||
@ -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
|
||||
// MioIsland Music Plugin
|
||||
//
|
||||
// Principal class for the music-player.bundle plugin.
|
||||
// Shows system Now Playing info (Spotify, Apple Music, etc.)
|
||||
// with playback controls in the notch.
|
||||
// Principal class for the music-player.bundle plugin (v2.0.0).
|
||||
//
|
||||
// Wires together the data layer (NowPlayingState + sources/*) and the UI
|
||||
// layer (ui/*). Loaded at runtime by the host's NativePluginManager via
|
||||
// Info.plist -> NSPrincipalClass = "MusicPlugin.MusicPlugin".
|
||||
//
|
||||
// v2.0.0 is a complete rewrite of the v1.0.0 shell. The old files
|
||||
// (NowPlayingBridge / MusicPlayerView / MusicHeaderButton) have been
|
||||
// replaced by a layered design:
|
||||
//
|
||||
// NowPlayingState -> orchestrator + sticky source routing
|
||||
// +-> sources/MediaRemoteSource (dlopen private framework)
|
||||
// +-> sources/SpotifyAppleScript
|
||||
// +-> sources/AppleMusicAppleScript
|
||||
// +-> sources/ChromeWebSource (JS injection into video/audio)
|
||||
// +-> support/ChineseAppDetector (QQ / NetEase / Kugou)
|
||||
// +-> support/HostVersionCheck (host >= 2.1.7 gate)
|
||||
//
|
||||
// ui/ExpandedView -> main panel (makeView)
|
||||
// ui/HeaderSlotView -> 20x20 header icon + pseudo-spectrum
|
||||
// ui/AlbumArtColorExtractor + ui/SeekBar
|
||||
// support/Localization (zh/en)
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Principal class. Module is `MusicPlugin`, class is `MusicPlugin`, so
|
||||
/// Info.plist NSPrincipalClass = "MusicPlugin.MusicPlugin".
|
||||
final class MusicPlugin: NSObject, MioPlugin {
|
||||
var id: String { "music-player" }
|
||||
var name: String { "Music Player" }
|
||||
var icon: String { "music.note" }
|
||||
var version: String { "1.0.0" }
|
||||
var version: String { "2.0.0" }
|
||||
|
||||
func activate() {
|
||||
NSLog("[mio-plugin-music] activate")
|
||||
Task { @MainActor in
|
||||
NowPlayingBridge.shared.start()
|
||||
NowPlayingState.shared.start()
|
||||
}
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
NSLog("[mio-plugin-music] deactivate")
|
||||
Task { @MainActor in
|
||||
NowPlayingBridge.shared.stop()
|
||||
NowPlayingState.shared.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func makeView() -> NSView {
|
||||
NSHostingView(rootView: MusicPlayerView())
|
||||
let view = NSHostingView(rootView: ExpandedView())
|
||||
view.autoresizingMask = [.width, .height]
|
||||
return view
|
||||
}
|
||||
|
||||
func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
|
||||
@objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
|
||||
switch slot {
|
||||
case "header":
|
||||
let view = NSHostingView(rootView: MusicHeaderButtonView())
|
||||
let view = NSHostingView(rootView: HeaderSlotView())
|
||||
view.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
||||
view.setFrameSize(NSSize(width: 20, height: 20))
|
||||
return view
|
||||
|
||||
@ -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"
|
||||
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
|
||||
BUILD_DIR="build"
|
||||
SOURCES="Sources/*.swift"
|
||||
|
||||
# Recursively pick up every .swift under Sources/ (root + subdirectories
|
||||
# like sources/, ui/, support/ for the v2.0.0 layered layout).
|
||||
SOURCES=$(find Sources -name "*.swift" -type f)
|
||||
|
||||
echo "Building ${PLUGIN_NAME} plugin..."
|
||||
echo "Compiling $(echo "$SOURCES" | wc -l | tr -d ' ') Swift files..."
|
||||
|
||||
# Clean
|
||||
rm -rf "${BUILD_DIR}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user