mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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>
370 lines
14 KiB
Swift
370 lines
14 KiB
Swift
//
|
|
// DesktopLyricsViews.swift
|
|
// MioIsland Music Plugin
|
|
//
|
|
// The three floating lyrics window variants. Shared traits:
|
|
// - NSVisualEffectView-backed blur via .background(.ultraThinMaterial)
|
|
// when available, fallback to semi-transparent color.
|
|
// - Draggable via the window's isMovableByWindowBackground (no extra
|
|
// gesture recognisers needed).
|
|
// - All text / progress / controls bound to NowPlayingState.shared.
|
|
// - Lyric lines are PLACEHOLDER text until we wire a real lyrics
|
|
// source. Only the `lyricLine(_:)` computed below changes when
|
|
// lyrics data becomes available.
|
|
//
|
|
|
|
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - Shared lyric slot helpers
|
|
|
|
private enum LyricSlot {
|
|
case previous
|
|
case current
|
|
case next
|
|
}
|
|
|
|
/// Pick the right synced-lyric line for a given slot. Falls back to a
|
|
/// sensible text when no lyrics are loaded so the window stays readable:
|
|
/// - previous / next → "······" (tastefully blank)
|
|
/// - current → track title on cold start, or
|
|
/// L10n.lyricsPlaceholder when paused / not-found
|
|
@MainActor
|
|
private func lyricText(_ slot: LyricSlot, state: NowPlayingState) -> String {
|
|
let lines = state.syncedLyrics
|
|
let idx = state.currentLyricIndex
|
|
|
|
if !lines.isEmpty {
|
|
switch slot {
|
|
case .previous:
|
|
let i = idx - 1
|
|
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
|
|
case .current:
|
|
if idx >= 0 && idx < lines.count { return lines[idx].text }
|
|
// Before first lyric line (elapsedTime < first timestamp).
|
|
return lines.first?.text ?? (state.title.isEmpty ? L10n.lyricsPlaceholder : state.title)
|
|
case .next:
|
|
let i = idx + 1
|
|
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
|
|
}
|
|
}
|
|
|
|
// No lyrics loaded / not found — graceful fallback.
|
|
switch slot {
|
|
case .previous: return "······"
|
|
case .current:
|
|
return state.isPlaying
|
|
? (state.title.isEmpty ? L10n.unknownTitle : state.title)
|
|
: L10n.lyricsPlaceholder
|
|
case .next:
|
|
return state.artist.isEmpty ? "······" : "— \(state.artist)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared SVG-equivalent transport controls
|
|
|
|
private struct MiniControls: View {
|
|
@ObservedObject var state: NowPlayingState = .shared
|
|
let playButtonSize: CGFloat
|
|
let iconButtonSize: CGFloat
|
|
let filledPlay: Bool // Bar/Karaoke use filled white; Cinema similar
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
button(icon: "backward.fill", size: iconButtonSize) {
|
|
state.previousTrack()
|
|
}
|
|
Button(action: { state.togglePlayPause() }) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(filledPlay ? Color.white.opacity(0.95) : Color.white.opacity(0.9))
|
|
.frame(width: playButtonSize, height: playButtonSize)
|
|
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.system(size: playButtonSize * 0.42, weight: .bold))
|
|
.foregroundColor(.black)
|
|
.offset(x: state.isPlaying ? 0 : 1)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
button(icon: "forward.fill", size: iconButtonSize) {
|
|
state.nextTrack()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func button(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: size * 0.48, weight: .semibold))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.frame(width: size, height: size)
|
|
.background(Circle().fill(Color.white.opacity(0.001)))
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private let floatBackground = Color(red: 0x12/255, green: 0x10/255, blue: 0x16/255).opacity(0.62)
|
|
private let floatStroke = Color.white.opacity(0.12)
|
|
|
|
/// ViewModifier applying the shared glass chrome (blur + border + shadow).
|
|
private struct FloatChrome: ViewModifier {
|
|
let radius: CGFloat
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.background(
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.fill(floatBackground)
|
|
}
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.strokeBorder(floatStroke, lineWidth: 0.5)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
|
.shadow(color: .black.opacity(0.5), radius: 30, y: 8)
|
|
.shadow(color: .black.opacity(0.4), radius: 8, y: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Rotating vinyl disc (for Bar + Cinema)
|
|
|
|
private struct VinylDisc: View {
|
|
let artwork: NSImage?
|
|
let isPlaying: Bool
|
|
let diameter: CGFloat
|
|
|
|
/// TimelineView drives rotation off the monotonic wall clock, which is
|
|
/// immune to SwiftUI re-creating the view (window hide/show, style
|
|
/// switch). `withAnimation(.repeatForever)` used to lose the animation
|
|
/// on re-creation and snap to rest. Wall-clock-based rotation just
|
|
/// always looks right — derive angle from `elapsed % 8s * 45°/s`.
|
|
@State private var pauseAccumulator: Double = 0
|
|
@State private var pauseStart: Date? = nil
|
|
|
|
var body: some View {
|
|
TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isPlaying)) { ctx in
|
|
let elapsed = ctx.date.timeIntervalSinceReferenceDate
|
|
// 8-second period → 45°/s. Multiplying by 45 and wrapping to
|
|
// [0, 360) keeps the rotation smooth across many hours without
|
|
// floating-point drift.
|
|
let angle = (elapsed * 45.0).truncatingRemainder(dividingBy: 360)
|
|
disc.rotationEffect(.degrees(angle))
|
|
}
|
|
}
|
|
|
|
private var disc: some View {
|
|
ZStack {
|
|
Circle().fill(Color.black)
|
|
if let art = artwork {
|
|
Image(nsImage: art)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: diameter * 0.55, height: diameter * 0.55)
|
|
.clipShape(Circle())
|
|
} else {
|
|
Circle()
|
|
.fill(LinearGradient(
|
|
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
|
|
Color(red: 0.27, green: 0.35, blue: 0.33)],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
))
|
|
.frame(width: diameter * 0.55, height: diameter * 0.55)
|
|
}
|
|
Circle()
|
|
.fill(Color.black)
|
|
.frame(width: diameter * 0.1, height: diameter * 0.1)
|
|
}
|
|
.frame(width: diameter, height: diameter)
|
|
.overlay(
|
|
Circle().strokeBorder(Color.white.opacity(0.15), lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Model 1 · Bar
|
|
|
|
struct LyricsBarView: View {
|
|
@ObservedObject var state: NowPlayingState = .shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 16) {
|
|
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 36)
|
|
|
|
Text(lyricText(.current, state: state))
|
|
.font(.system(size: 20, weight: .medium))
|
|
.tracking(-0.1)
|
|
.foregroundColor(.white.opacity(0.95))
|
|
.lineLimit(1)
|
|
.shadow(color: .black.opacity(0.4), radius: 6, y: 2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
MiniControls(state: state, playButtonSize: 28, iconButtonSize: 24, filledPlay: true)
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 14)
|
|
.modifier(FloatChrome(radius: 999))
|
|
.padding(4) // breathing room so shadow isn't clipped by window
|
|
}
|
|
}
|
|
|
|
// MARK: - Model 2 · Karaoke
|
|
|
|
struct LyricsKaraokeView: View {
|
|
@ObservedObject var state: NowPlayingState = .shared
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(Color(red: 0.9, green: 0.72, blue: 0.53))
|
|
.frame(width: 5, height: 5)
|
|
Text(state.sourceName.isEmpty
|
|
? (L10n.isChinese ? "歌词同步" : "Lyrics Sync")
|
|
: "\(L10n.isChinese ? "歌词同步 · " : "Lyrics · ")\(state.sourceName)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.55))
|
|
Text("·")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.3))
|
|
Text("\(state.formattedElapsed) / \(state.formattedDuration)")
|
|
.font(.system(size: 10.5, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.55))
|
|
Spacer()
|
|
Text("⋮⋮ drag")
|
|
.font(.system(size: 9.5, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.3))
|
|
}
|
|
.padding(.bottom, 12)
|
|
|
|
// Current (big)
|
|
Text(lyricText(.current, state: state))
|
|
.font(.system(size: 26, weight: .semibold))
|
|
.tracking(-0.3)
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
.shadow(color: .black.opacity(0.35), radius: 10, y: 3)
|
|
|
|
// Next (faint)
|
|
Text(lyricText(.next, state: state))
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.42))
|
|
.lineLimit(1)
|
|
.padding(.top, 6)
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.vertical, 14)
|
|
|
|
// Meta + controls row
|
|
HStack(alignment: .center, spacing: 10) {
|
|
albumArtSmall
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.85))
|
|
.lineLimit(1)
|
|
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
|
|
.font(.system(size: 10.5))
|
|
.foregroundColor(.white.opacity(0.45))
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
MiniControls(state: state, playButtonSize: 32, iconButtonSize: 28, filledPlay: true)
|
|
}
|
|
}
|
|
.padding(EdgeInsets(top: 22, leading: 26, bottom: 18, trailing: 26))
|
|
.modifier(FloatChrome(radius: 20))
|
|
.padding(4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var albumArtSmall: some View {
|
|
if let art = state.albumArt {
|
|
Image(nsImage: art)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 28, height: 28)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(LinearGradient(
|
|
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
|
|
Color(red: 0.27, green: 0.35, blue: 0.33)],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
))
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Model 3 · Cinema
|
|
|
|
struct LyricsCinemaView: View {
|
|
@ObservedObject var state: NowPlayingState = .shared
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Prev line (faint)
|
|
Text(lyricText(.previous, state: state))
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.3))
|
|
.lineLimit(1)
|
|
.padding(.bottom, 12)
|
|
|
|
// Now line (huge)
|
|
Text(lyricText(.current, state: state))
|
|
.font(.system(size: 34, weight: .bold))
|
|
.tracking(-0.6)
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
.shadow(color: .black.opacity(0.4), radius: 16, y: 3)
|
|
|
|
// Next line (faint)
|
|
Text(lyricText(.next, state: state))
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.4))
|
|
.lineLimit(1)
|
|
.padding(.top, 12)
|
|
|
|
Divider()
|
|
.background(Color.white.opacity(0.08))
|
|
.padding(.top, 28)
|
|
.padding(.bottom, 18)
|
|
|
|
// Footer
|
|
HStack(spacing: 10) {
|
|
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 22)
|
|
|
|
Text("\(state.title.isEmpty ? L10n.unknownTitle : state.title) · \(state.artist.isEmpty ? L10n.unknownArtist : state.artist)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.55))
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
MiniControls(state: state, playButtonSize: 30, iconButtonSize: 30, filledPlay: true)
|
|
}
|
|
}
|
|
.padding(EdgeInsets(top: 40, leading: 40, bottom: 28, trailing: 40))
|
|
.background(
|
|
// Faint color wash for cinema feel
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.9, green: 0.72, blue: 0.53).opacity(0.10),
|
|
Color.clear
|
|
],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.modifier(FloatChrome(radius: 24))
|
|
.padding(4)
|
|
}
|
|
}
|