904b9b3d-c0eb-42f3-acef-958.../Sources/ui/ExpandedView.swift
徐翔宇 69776ecec2 v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl
Critical fix · adapter stream was silently empty

v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The
`stream` mode wraps every emit as:

    {"type":"data","diff":<bool>,"payload":{title,...}}

but Swift was decoding as AdapterStreamPayload directly (the shape
used by `get`, which is flat). Result: every `stream rx` had
title="" because the real data was nested inside payload, so
hasTrack was always false and onUpdate never fired. Users saw
"nothing playing" even while Apple Music was running.

New AdapterStreamEnvelope decodes the wrapper, extracts payload,
and also honours `diff: false` to reset currentInfo before merging
(stale fields from the previous track were otherwise leaking).

Added bootstrap path · cold start with music already playing

When the adapter subprocess is spawned AFTER Apple Music is already
playing, the stream's initial emit can be an empty baseline. A
parallel one-shot `perl adapter.pl get` at spawn+300ms catches the
current track immediately.

Added file-based debug log at /tmp/mio-plugin-music-debug.log

NSLog / os_log are unreliably filtered on macOS 15, and we can't
attach Xcode to a plugin loaded from a signed host. A line-oriented
log at a fixed path is the one channel that's always readable post-
mortem. Lines include stream rx / bootstrap / parse failures.

Real lyrics · LRCLIB integration

- New LyricsService fetches synced lyrics via
  https://lrclib.net/api/get (exact) + /search (fallback), parses
  LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries,
  1-hour TTL). Negative-caches "not found" so obscure tracks don't
  re-hit the API every render.
- NowPlayingState gains syncedLyrics + currentLyricIndex @Published.
  applyAdapterUpdate detects track changes and refreshes lyrics
  detached; the 1s playback timer updates currentLyricIndex.
- DesktopLyricsViews replaces the placeholder text with real lyric
  text from syncedLyrics[currentLyricIndex ± 1]. Falls back to
  sensible dots when no lyrics loaded / instrumentals.

Bonus · robust vinyl spin via TimelineView

withAnimation(.linear.repeatForever) loses the animation when
SwiftUI re-creates the view (window hide/show, style switch).
Replaced with TimelineView driven by wall-clock — angle =
(elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses
correctly via `paused: !isPlaying`.

Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll)
MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex
shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 07:55:04 +08:00

434 lines
14 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)
//
// v2.0.1 layout: compact horizontal hero inspired by SuperIsland's
// NowPlaying. Medium album art on the left, metadata + source badge
// on the right, progress + times inline below, transport controls
// at bottom. Half the vertical footprint of v2.0.0 for the same
// information density.
//
// Background uses a tint extracted from the album art (fades into
// a near-black base). Palette:
// #0A0A0A near-black base
// white text tiers 1.0 / 0.75 / 0.45 / 0.3
// lime #CAFF00 as the single accent color
// 16pt corner on the big art, 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(alignment: .center) {
// V2 Immersive backdrop applied only when actually playing,
// other modes (empty / warning) use the plain near-black base.
if currentMode == .playing {
immersiveBackdrop
.ignoresSafeArea()
}
content
.padding(20)
// Top-right float-window toggle only when playing.
if currentMode == .playing {
VStack {
HStack {
Spacer()
floatWindowToggle
}
Spacer()
}
.padding(14)
}
}
.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)
}
/// V2 Immersive backdrop: blurred enlarged album art + dark gradient
/// overlay (35% 72% 92%). Falls back to a solid base when no art.
@ViewBuilder
private var immersiveBackdrop: some View {
if let art = state.albumArt {
ZStack {
Image(nsImage: art)
.resizable()
.scaledToFill()
.saturation(1.4)
.blur(radius: 40, opaque: true)
.scaleEffect(1.3)
.clipped()
LinearGradient(
gradient: Gradient(stops: [
.init(color: Self.base.opacity(0.35), location: 0.0),
.init(color: Self.base.opacity(0.72), location: 0.55),
.init(color: Self.base.opacity(0.92), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
}
} else {
AlbumArtColorExtractor.backgroundGradient(for: tintColor)
}
}
/// Pin/float button that toggles the desktop lyrics overlay.
/// Icon is static (`pip.enter`) we don't observe the window to keep
/// this view free of a @StateObject dependency. The window itself is
/// the visibility signal.
private var floatWindowToggle: some View {
Button {
DesktopLyricsWindow.shared.toggle()
} label: {
Image(systemName: "pip.enter")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.85))
.frame(width: 28, height: 28)
.background(
Circle().fill(Color.black.opacity(0.35))
)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.help(L10n.floatLyricsTooltip)
}
// 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 compact horizontal layout
private var playingCard: some View {
// V2 Immersive layout centered column: cover 120 title artist
// progress controls with outline play button. Matches the
// Claude Design CodeIsland Music.html V2 spec.
VStack(spacing: 14) {
// Large centered album art shadow drops onto blurred backdrop
albumArt
.frame(width: 120, height: 120)
VStack(spacing: 4) {
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 19, weight: .semibold))
.tracking(-0.35)
.foregroundColor(Self.ink)
.lineLimit(1)
.shadow(color: .black.opacity(0.35), radius: 6, y: 2)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 13))
.foregroundColor(Self.ink.opacity(0.78))
.lineLimit(1)
}
.frame(maxWidth: 360)
// Source chip (below artist, subtle)
sourceBadge
// Progress bar + times
VStack(spacing: 8) {
SeekBar(
progress: state.progress,
duration: state.duration
) { newTime in
state.seek(to: newTime)
}
HStack {
Text(state.formattedElapsed)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55))
Spacer()
Text(state.formattedDuration)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.55))
}
}
.padding(.top, 6)
Spacer(minLength: 0)
transportControls
}
.frame(maxWidth: 380)
}
private var albumArt: some View {
ZStack {
if let art = state.albumArt {
Image(nsImage: art)
.resizable()
.aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white.opacity(0.08))
.overlay(
Image(systemName: "music.note")
.font(.system(size: 34, weight: .light))
.foregroundColor(Self.ink.opacity(0.35))
)
}
}
.shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10)
}
private var sourceBadge: some View {
HStack(spacing: 5) {
Circle()
.fill(state.isPlaying ? Self.lime : Self.ink.opacity(0.4))
.frame(width: 5, height: 5)
Text(displaySourceName)
.font(.system(size: 10, weight: .medium))
.foregroundColor(Self.ink.opacity(0.7))
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(
Capsule().fill(Color.white.opacity(0.08))
)
}
private var displaySourceName: String {
state.sourceName.isEmpty ? "..." : state.sourceName
}
private var transportControls: some View {
HStack(spacing: 28) {
transportButton(
symbol: "backward.fill",
size: 16,
tooltip: L10n.previousTooltip
) {
state.previousTrack()
}
// Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56)
// V2 Immersive: outline play button (1.5px 85% white) lets the
// blurred album backdrop breathe through instead of punching a
// big lime disc that fights the art.
Button(action: { state.togglePlayPause() }) {
ZStack {
Circle()
.strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5)
.frame(width: 48, height: 48)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Self.ink)
.offset(x: state.isPlaying ? 0 : 2)
}
}
.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: 16,
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: 12) {
Image(systemName: "music.note")
.font(.system(size: 40, weight: .light))
.foregroundColor(Self.ink.opacity(0.3))
Text(L10n.nothingPlaying)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.7))
Text(L10n.nothingPlayingHint)
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.4))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(24)
.frame(maxWidth: 320)
}
// MARK: - Warning cards (host outdated / chinese app detected)
private func warningCard(
symbol: String,
title: String,
hint: String,
tint: Color
) -> some View {
VStack(spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 36, weight: .regular))
.foregroundColor(tint)
Text(title)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.9))
.multilineTextAlignment(.center)
Text(hint)
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.55))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(2)
}
.padding(22)
.frame(maxWidth: 340)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.white.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 14, 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: 36, height: 36)
.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
}
}
}