mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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>
377 lines
12 KiB
Swift
377 lines
12 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|