NavidromeApp/iOS/Views/Lyrics/LyricsService.swift
Dallas Groot cea4e3868e Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches.
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.
2026-04-14 00:27:10 -07:00

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
)
}
}