904b9b3d-c0eb-42f3-acef-958.../Sources/sources/LyricsService.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

243 lines
9.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LyricsService.swift
// MioIsland Music Plugin
//
// Fetches synced lyrics from LRCLIB (https://lrclib.net/docs) and parses
// their LRC format into per-line timestamps. Free public API, no auth.
//
// Approach borrowed from Atoll (github.com/Ebullioscopic/Atoll,
// MusicManager.swift:756895). Two lookup endpoints:
//
// /api/get?track_name=&artist_name=&album_name=&duration=
// exact match with all four params; best hit rate when present.
//
// /api/search?track_name=&artist_name=
// fallback text search, returns an array; we take the first.
//
// LRC lines look like "[mm:ss.xx] Lyric line". We regex-extract the
// timestamp + trailing text. Centiseconds optional.
//
// Caching: in-memory LRU keyed by (artist + title + duration-bucket).
// Bucket duration to nearest second so slight float drift between
// MediaRemote and LRCLIB doesn't create separate cache keys. Cache
// size capped at 32 entries plenty for a single listening session.
//
import Foundation
// MARK: - Public types
struct LyricLine: Equatable, Identifiable {
let id = UUID()
let timestamp: TimeInterval
let text: String
}
enum LyricsService {
// MARK: - Cache
/// Cache entry an empty array means "we tried, nothing found".
/// This negative-cache prevents hammering LRCLIB on songs with no
/// lyrics (e.g. instrumentals).
private struct CacheEntry {
let lines: [LyricLine]
let cachedAt: Date
}
private static let cacheQueue = DispatchQueue(
label: "mio-plugin-music.lyrics-cache",
attributes: .concurrent
)
private static var _cache: [String: CacheEntry] = [:]
private static let maxCacheSize = 32
private static let cacheTTL: TimeInterval = 60 * 60 // 1 hour
private static func cacheKey(artist: String, title: String, duration: TimeInterval) -> String {
let bucket = Int(duration.rounded())
return "\(artist.lowercased())|\(title.lowercased())|\(bucket)"
}
private static func lookupCache(key: String) -> [LyricLine]? {
var result: [LyricLine]?
cacheQueue.sync {
if let entry = _cache[key],
Date().timeIntervalSince(entry.cachedAt) < cacheTTL {
result = entry.lines
}
}
return result
}
private static func storeCache(key: String, lines: [LyricLine]) {
cacheQueue.async(flags: .barrier) {
if _cache.count >= maxCacheSize {
// Naive eviction: drop the oldest entry. Perfect LRU
// isn't worth extra bookkeeping for N=32.
if let oldestKey = _cache.min(by: { $0.value.cachedAt < $1.value.cachedAt })?.key {
_cache.removeValue(forKey: oldestKey)
}
}
_cache[key] = CacheEntry(lines: lines, cachedAt: Date())
}
}
// MARK: - Fetch
/// Fetch synced lyrics for the given track. Returns an empty array on
/// "tried and no lyrics found" the caller should treat nil (error)
/// and [] (no lyrics) as distinct states for UX. Safe to call off the
/// main actor; result is not main-isolated.
static func fetch(
artist: String,
title: String,
album: String = "",
duration: TimeInterval = 0
) async -> [LyricLine] {
let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedArtist.isEmpty, !trimmedTitle.isEmpty else { return [] }
let key = cacheKey(artist: trimmedArtist, title: trimmedTitle, duration: duration)
if let cached = lookupCache(key: key) { return cached }
// 1. Exact match (best hit rate when album + duration are known).
if duration > 0 {
if let lines = try? await fetchExact(
artist: trimmedArtist,
title: trimmedTitle,
album: album,
duration: duration
) {
storeCache(key: key, lines: lines)
return lines
}
}
// 2. Search fallback.
if let lines = try? await fetchSearch(artist: trimmedArtist, title: trimmedTitle) {
storeCache(key: key, lines: lines)
return lines
}
storeCache(key: key, lines: [])
return []
}
private static let baseURL = "https://lrclib.net/api"
private struct GetResponse: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private struct SearchResultItem: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private static func fetchExact(
artist: String,
title: String,
album: String,
duration: TimeInterval
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/get")!
comps.queryItems = [
URLQueryItem(name: "artist_name", value: artist),
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "album_name", value: album),
URLQueryItem(name: "duration", value: String(Int(duration.rounded())))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let decoded = try? JSONDecoder().decode(GetResponse.self, from: data) {
if let synced = decoded.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = decoded.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
private static func fetchSearch(
artist: String,
title: String
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/search")!
comps.queryItems = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist)
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let items = try? JSONDecoder().decode([SearchResultItem].self, from: data),
let first = items.first {
if let synced = first.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = first.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
// MARK: - LRC parsing
/// LRC timestamp regex matches [mm:ss] and [mm:ss.xx] (centiseconds
/// optional). Captures three groups: minutes, seconds, centiseconds.
private static let lrcRegex: NSRegularExpression = {
// Force-try here: pattern is static and known-valid at compile time.
// swiftlint:disable:next force_try
try! NSRegularExpression(
pattern: "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]",
options: []
)
}()
static func parseLRC(_ lrc: String) -> [LyricLine] {
var out: [LyricLine] = []
for raw in lrc.components(separatedBy: .newlines) {
let ns = raw as NSString
let range = NSRange(location: 0, length: ns.length)
guard let match = lrcRegex.firstMatch(in: raw, options: [], range: range) else {
continue
}
let minutes = Double(ns.substring(with: match.range(at: 1))) ?? 0
let seconds = Double(ns.substring(with: match.range(at: 2))) ?? 0
let centi: Double = {
let r = match.range(at: 3)
return r.location != NSNotFound ? (Double(ns.substring(with: r)) ?? 0) : 0
}()
let ts = minutes * 60 + seconds + centi / 100.0
let textStart = match.range.location + match.range.length
guard textStart <= ns.length else { continue }
let text = ns.substring(from: textStart).trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { continue }
out.append(LyricLine(timestamp: ts, text: text))
}
return out.sorted { $0.timestamp < $1.timestamp }
}
}