Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes). Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over. The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
178 lines
6.7 KiB
Swift
178 lines
6.7 KiB
Swift
import Foundation
|
|
import AVFoundation
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// LyricsService.swift
|
|
// iOS-native lyrics fetching — no Companion API dependency.
|
|
//
|
|
// Priority:
|
|
// 1. Local cache (previously fetched)
|
|
// 2. Embedded lyrics in downloaded file (AVAsset metadata)
|
|
// 3. LRCLIB direct (free public API, no auth)
|
|
// 4. Companion API fallback (for server-side embedded tags)
|
|
//
|
|
// LRCLIB API: https://lrclib.net/api
|
|
// GET /search?q=... → [LRCLIBResult]
|
|
// GET /get?artist_name=...&track_name=...&duration=... → LRCLIBResult
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
final class LyricsService {
|
|
static let shared = LyricsService()
|
|
|
|
private let lrclibBase = "https://lrclib.net/api"
|
|
private let session: URLSession
|
|
|
|
private init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 10
|
|
config.timeoutIntervalForResource = 15
|
|
session = URLSession(configuration: config)
|
|
}
|
|
|
|
// MARK: - Search LRCLIB (direct)
|
|
|
|
/// Search LRCLIB for lyrics matching a free-text query.
|
|
/// Returns an array of matches with synced/plain lyrics included.
|
|
func search(query: String) async throws -> [LRCLIBResult] {
|
|
guard var components = URLComponents(string: "\(lrclibBase)/search") else {
|
|
throw LyricsServiceError.invalidURL
|
|
}
|
|
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
|
guard let url = components.url else { throw LyricsServiceError.invalidURL }
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
throw LyricsServiceError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
|
|
}
|
|
|
|
// LRCLIB returns slightly different field names than our model
|
|
let raw = try JSONDecoder().decode([LRCLIBRawResult].self, from: data)
|
|
return raw.map { $0.toLRCLIBResult() }
|
|
}
|
|
|
|
// MARK: - Fetch from LRCLIB (exact match)
|
|
|
|
/// Exact-match fetch from LRCLIB by artist + title + duration.
|
|
func fetch(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
|
guard var components = URLComponents(string: "\(lrclibBase)/get") else {
|
|
throw LyricsServiceError.invalidURL
|
|
}
|
|
var items = [
|
|
URLQueryItem(name: "artist_name", value: artist),
|
|
URLQueryItem(name: "track_name", value: title),
|
|
]
|
|
if duration > 0 {
|
|
items.append(URLQueryItem(name: "duration", value: String(Int(duration))))
|
|
}
|
|
components.queryItems = items
|
|
guard let url = components.url else { throw LyricsServiceError.invalidURL }
|
|
|
|
let (data, response) = try await session.data(from: url)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw LyricsServiceError.httpError(0)
|
|
}
|
|
|
|
if http.statusCode == 404 {
|
|
return LyricsResponse(syncedLyrics: nil, plainLyrics: nil, source: "lrclib", cached: false, found: false)
|
|
}
|
|
guard http.statusCode == 200 else {
|
|
throw LyricsServiceError.httpError(http.statusCode)
|
|
}
|
|
|
|
let raw = try JSONDecoder().decode(LRCLIBRawResult.self, from: data)
|
|
return LyricsResponse(
|
|
syncedLyrics: raw.syncedLyrics,
|
|
plainLyrics: raw.plainLyrics,
|
|
source: "lrclib",
|
|
cached: false,
|
|
found: raw.syncedLyrics != nil || raw.plainLyrics != nil
|
|
)
|
|
}
|
|
|
|
// MARK: - Read Embedded Lyrics (on-device)
|
|
|
|
/// Read lyrics embedded in a local audio file's metadata tags.
|
|
/// Works for downloaded songs — checks USLT (MP3), LYRICS (FLAC), ©lyr (M4A).
|
|
func readEmbedded(songId: String) async -> LyricsResponse? {
|
|
guard let localURL = OfflineManager.shared.localURL(for: songId) else { return nil }
|
|
|
|
let asset = AVURLAsset(url: localURL)
|
|
var lyricsText: String?
|
|
|
|
do {
|
|
// Modern async API (iOS 16+)
|
|
let commonItems = try await asset.load(.commonMetadata)
|
|
for item in commonItems {
|
|
if item.commonKey == .commonKeyTitle { continue }
|
|
if let key = item.commonKey?.rawValue, key.lowercased().contains("lyric") {
|
|
lyricsText = try await item.load(.stringValue)
|
|
}
|
|
}
|
|
|
|
if lyricsText == nil {
|
|
let allItems = try await asset.load(.metadata)
|
|
for item in allItems {
|
|
if let key = item.identifier?.rawValue.lowercased(),
|
|
(key.contains("lyric") || key.contains("uslt") || key.contains("©lyr")) {
|
|
lyricsText = try await item.load(.stringValue)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
|
|
guard let text = lyricsText, !text.isEmpty else { return nil }
|
|
|
|
let hasTimes = text.contains("[") && text.range(of: #"\[\d{1,2}:\d{2}"#, options: .regularExpression) != nil
|
|
|
|
return LyricsResponse(
|
|
syncedLyrics: hasTimes ? text : nil,
|
|
plainLyrics: hasTimes ? nil : text,
|
|
source: "embedded",
|
|
cached: false,
|
|
found: true
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum LyricsServiceError: LocalizedError {
|
|
case invalidURL
|
|
case httpError(Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL: return "Invalid lyrics URL"
|
|
case .httpError(let code): return "Lyrics request failed (HTTP \(code))"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - LRCLIB Raw Response (matches their actual API format)
|
|
|
|
/// LRCLIB's native response format — field names differ from our app model.
|
|
private struct LRCLIBRawResult: Codable {
|
|
let id: Int?
|
|
let trackName: String?
|
|
let artistName: String?
|
|
let albumName: String?
|
|
let duration: Double?
|
|
let syncedLyrics: String?
|
|
let plainLyrics: String?
|
|
|
|
func toLRCLIBResult() -> LRCLIBResult {
|
|
LRCLIBResult(
|
|
id: id,
|
|
trackName: trackName ?? "",
|
|
artistName: artistName ?? "",
|
|
albumName: albumName,
|
|
duration: duration ?? 0,
|
|
hasSynced: syncedLyrics != nil,
|
|
hasPlain: plainLyrics != nil,
|
|
syncedLyrics: syncedLyrics,
|
|
plainLyrics: plainLyrics
|
|
)
|
|
}
|
|
}
|