feat: MioIsland music player plugin — reads system NowPlaying

This commit is contained in:
xmqywx 2026-04-11 23:37:11 +08:00
commit d044a6f0c0
10 changed files with 563 additions and 0 deletions

24
Info.plist Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>MusicPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.music-player</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Music Player</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string>
</dict>
</plist>

21
Sources/MioPlugin.swift Normal file
View File

@ -0,0 +1,21 @@
//
// MioPlugin.swift
// MioIsland Plugin SDK
//
// Duplicate of the protocol from the host app. At runtime, @objc
// protocol conformance is matched by selector signatures, not by
// module identity, so this standalone copy works for .bundle plugins.
//
import AppKit
@objc protocol MioPlugin: AnyObject {
var id: String { get }
var name: String { get }
var icon: String { get }
var version: String { get }
func activate()
func deactivate()
func makeView() -> NSView
@objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView?
}

View File

@ -0,0 +1,54 @@
//
// MusicHeaderButton.swift
// MioIsland Music Plugin
//
// Small button for the "header" slot shows a music note icon
// that posts a notification to open the music plugin view.
//
import AppKit
import SwiftUI
/// Notification name that the host app listens for to navigate to a plugin.
/// The userInfo dict contains ["pluginId": String].
extension Notification.Name {
static let openPlugin = Notification.Name("com.codeisland.openPlugin")
}
struct MusicHeaderButtonView: View {
@ObservedObject var bridge = NowPlayingBridge.shared
@State private var isHovered = false
var body: some View {
Button {
NotificationCenter.default.post(
name: .openPlugin,
object: nil,
userInfo: ["pluginId": "music-player"]
)
} label: {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundColor(
isHovered
? Color(red: 1.0, green: 0.4, blue: 0.6) //
: (bridge.info.isPlaying ? .white.opacity(0.8) : .white.opacity(0.4))
)
.scaleEffect(isHovered ? 1.15 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHovered)
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering in
isHovered = hovering
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
.frame(width: 20, height: 20)
.fixedSize()
}
}

View File

@ -0,0 +1,116 @@
//
// 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)
}
}

46
Sources/MusicPlugin.swift Normal file
View File

@ -0,0 +1,46 @@
//
// 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.
//
import AppKit
import SwiftUI
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" }
func activate() {
Task { @MainActor in
NowPlayingBridge.shared.start()
}
}
func deactivate() {
Task { @MainActor in
NowPlayingBridge.shared.stop()
}
}
func makeView() -> NSView {
NSHostingView(rootView: MusicPlayerView())
}
func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
switch slot {
case "header":
let view = NSHostingView(rootView: MusicHeaderButtonView())
view.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
view.setFrameSize(NSSize(width: 20, height: 20))
return view
default:
return nil
}
}
}

View File

@ -0,0 +1,129 @@
//
// 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
}
}

34
build.sh Executable file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# Build the Music Player plugin as a .bundle for MioIsland
set -e
PLUGIN_NAME="music-player"
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
BUILD_DIR="build"
SOURCES="Sources/*.swift"
echo "Building ${PLUGIN_NAME} plugin..."
# Clean
rm -rf "${BUILD_DIR}"
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS"
# Compile to dynamic library
swiftc \
-emit-library \
-module-name MusicPlugin \
-target arm64-apple-macos15.0 \
-sdk $(xcrun --show-sdk-path) \
-o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/MusicPlugin" \
${SOURCES}
# Copy Info.plist
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
# Ad-hoc sign
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
echo ""
echo "Install:"
echo " cp -r ${BUILD_DIR}/${BUNDLE_NAME} ~/.config/codeisland/plugins/"

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>MusicPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.music-player</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Music Player</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string>
</dict>
</plist>

Binary file not shown.

View File

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict/>
<key>files2</key>
<dict/>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>