904b9b3d-c0eb-42f3-acef-958.../Sources/ui/ExpandedView.swift

377 lines
12 KiB
Swift
Raw Permalink Normal View History

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>
2026-04-18 18:27:21 +00:00
//
// 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
}
}
}