commit d044a6f0c0b8763cc7204bd6d467b1c32e7247be Author: xmqywx Date: Sat Apr 11 23:37:11 2026 +0800 feat: MioIsland music player plugin — reads system NowPlaying diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..2591f5c --- /dev/null +++ b/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + MusicPlugin + CFBundleIdentifier + com.mioisland.plugin.music-player + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Music Player + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSPrincipalClass + MusicPlugin.MusicPlugin + + diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..96cb442 --- /dev/null +++ b/Sources/MioPlugin.swift @@ -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? +} diff --git a/Sources/MusicHeaderButton.swift b/Sources/MusicHeaderButton.swift new file mode 100644 index 0000000..49cdaed --- /dev/null +++ b/Sources/MusicHeaderButton.swift @@ -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() + } +} diff --git a/Sources/MusicPlayerView.swift b/Sources/MusicPlayerView.swift new file mode 100644 index 0000000..eb90d50 --- /dev/null +++ b/Sources/MusicPlayerView.swift @@ -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) + } +} diff --git a/Sources/MusicPlugin.swift b/Sources/MusicPlugin.swift new file mode 100644 index 0000000..fe7b1b2 --- /dev/null +++ b/Sources/MusicPlugin.swift @@ -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 + } + } +} diff --git a/Sources/NowPlayingBridge.swift b/Sources/NowPlayingBridge.swift new file mode 100644 index 0000000..3ca0437 --- /dev/null +++ b/Sources/NowPlayingBridge.swift @@ -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 + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..f2edc45 --- /dev/null +++ b/build.sh @@ -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/" diff --git a/build/music-player.bundle/Contents/Info.plist b/build/music-player.bundle/Contents/Info.plist new file mode 100644 index 0000000..2591f5c --- /dev/null +++ b/build/music-player.bundle/Contents/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + MusicPlugin + CFBundleIdentifier + com.mioisland.plugin.music-player + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Music Player + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSPrincipalClass + MusicPlugin.MusicPlugin + + diff --git a/build/music-player.bundle/Contents/MacOS/MusicPlugin b/build/music-player.bundle/Contents/MacOS/MusicPlugin new file mode 100755 index 0000000..c070cc0 Binary files /dev/null and b/build/music-player.bundle/Contents/MacOS/MusicPlugin differ diff --git a/build/music-player.bundle/Contents/_CodeSignature/CodeResources b/build/music-player.bundle/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..d5d0fd7 --- /dev/null +++ b/build/music-player.bundle/Contents/_CodeSignature/CodeResources @@ -0,0 +1,115 @@ + + + + + files + + files2 + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + +