diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 31b27d8..16b825b 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -429,8 +429,34 @@ class AudioPlayer: NSObject, ObservableObject { let nextSong = SmartCrossfadeManager.shared.pendingNextSong, let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong) else { return } + // Clear stale vis from outgoing song before loading new + self.offlineVisBuffer = VisFrameBuffer.empty + self.setLevels(Array(repeating: 0, count: self._audioLevels.count)) self.loadOfflineVisualizer(songId: nextSong.id, url: url) } + // At crossfade midpoint, transition metadata/artwork/colors/widget + // so the UI reflects the incoming song while audio is still fading. + crossfade.songHandoff = { [weak self] incomingSong in + guard let self = self else { return } + // Scrobble the outgoing song before updating currentSong + if let outgoing = self.currentSong { + Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) } + } + self.currentSong = incomingSong + if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) { + self.queueIndex = idx + } + self.updateNowPlayingInfo() + self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt) + self.pushWidgetState() + LyricsManager.shared.songChanged( + songId: incomingSong.id, + artist: incomingSong.artist ?? "Unknown", + title: incomingSong.title, + path: incomingSong.path, + duration: self.duration + ) + } crossfade.playSong(song, url: url) isPlaying = true @@ -1109,20 +1135,20 @@ class AudioPlayer: NSObject, ObservableObject { crossfade.needsNextTrack = { [weak self] in guard let self = self else { return } - // Scrobble the song that just finished. - // playerDidFinish only fires on natural item end — crossfade replaces the item - // before that happens, so we scrobble here instead. - if let finishedSong = self.currentSong { - Task { try? await ServerManager.shared.client.scrobble(id: finishedSong.id) } - } + // Scrobble + currentSong + artwork already handled by songHandoff at midpoint. + // Here we only handle post-swap housekeeping. - // Sync queueIndex to whatever was actually in standby (pendingNextSong), - // not just queueIndex+1. The queue may have changed between prepareNext() - // and now — this guarantees currentSong matches what the active player is playing. + // Ensure queueIndex is synced (songHandoff may have set it, but verify) if let nowPlaying = crossfade.pendingNextSong, let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) { self.queueIndex = idx - self.currentSong = nowPlaying + // Only update currentSong if songHandoff somehow didn't fire + if self.currentSong?.id != nowPlaying.id { + self.currentSong = nowPlaying + self.updateNowPlayingInfo() + self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt) + self.pushWidgetState() + } } else { // Fallback: queue was cleared or song removed — advance sequentially let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count @@ -1133,21 +1159,22 @@ class AudioPlayer: NSObject, ObservableObject { self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1) } self.currentSong = self.queue[self.queueIndex] + self.updateNowPlayingInfo() + self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt) + self.pushWidgetState() } } - // Persist updated queue position — time observer only saves position now + // Persist updated queue position PlaybackStateStore.shared.save( queue: self.queue, index: self.queueIndex, currentTime: 0, currentSongId: self.currentSong?.id ) + // Swap AudioPlayer.player to the new active crossfade player self.player = crossfade.activePlayer - // Re-register the safety-net AVPlayerItemDidPlayToEndTime observer on the - // new active item. play() registered it on the first item only — subsequent - // crossfades swap the active slot so the original observer is now stale. - // Without this, a boundary-observer failure on track 2+ has no fallback. + // Re-register the safety-net AVPlayerItemDidPlayToEndTime observer NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) if let newItem = crossfade.activePlayer.currentItem { NotificationCenter.default.addObserver( @@ -1156,10 +1183,6 @@ class AudioPlayer: NSObject, ObservableObject { ) } - self.updateNowPlayingInfo() - self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt) - self.pushWidgetState() - // Prepare next-next (picks up any queue changes that happened mid-fade) self.prepareNextForCrossfade() } @@ -1798,8 +1821,21 @@ class AudioPlayer: NSObject, ObservableObject { duration: duration ) - let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3)) - .map { (title: $0.title, artist: $0.artist ?? "Unknown") } + let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3)) + let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in + var artData: Data? + if let id = s.coverArt { + if let custom = AlbumCoverStore.shared.loadCover(for: id) { + // Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px) + artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))? + .jpegData(compressionQuality: 0.5) + } else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) { + artData = ImageCache.shared.cachedImage(for: url)? + .jpegData(compressionQuality: 0.5) + } + } + return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData) + } // Cover art: custom first, then server (memory + disk). var coverImage: UIImage? @@ -1926,10 +1962,17 @@ class AudioPlayer: NSObject, ObservableObject { SmartCrossfadeManager.shared.stop() isUsingCrossfade = false } + // Zero the vis buffer so stale frames from the previous song don't render + // while the new song is loading. The new loadOfflineVisualizer() call will + // repopulate it once analysis finishes. + offlineVisBuffer = VisFrameBuffer.empty #endif stopEngine() stopAVPlayer() NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + // Zero audio levels immediately — prevents the Canvas from painting stale + // FFT/vis data during track transitions (the "lifted bars" bug). + setLevels(Array(repeating: 0, count: _audioLevels.count)) } func stop() { diff --git a/Shared/Storage/WidgetSharedState.swift b/Shared/Storage/WidgetSharedState.swift index 2b53994..ed77364 100644 --- a/Shared/Storage/WidgetSharedState.swift +++ b/Shared/Storage/WidgetSharedState.swift @@ -16,6 +16,7 @@ import Foundation struct WidgetQueueItem: Codable, Equatable { let title: String let artist: String + let coverArtData: Data? // small JPEG thumbnail (~40×40 → ~2KB each) } /// Commands the widget can send to the main app. diff --git a/Widget/NowPlayingWidget.swift b/Widget/NowPlayingWidget.swift index d071676..47741ef 100644 --- a/Widget/NowPlayingWidget.swift +++ b/Widget/NowPlayingWidget.swift @@ -179,5 +179,6 @@ struct NowPlayingWidget: Widget { .configurationDisplayName("Now Playing") .description("Control playback and see what's playing.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + .contentMarginsDisabled() } } diff --git a/Widget/NowPlayingWidgetViews.swift b/Widget/NowPlayingWidgetViews.swift index 2f3a1be..5cdc19c 100644 --- a/Widget/NowPlayingWidgetViews.swift +++ b/Widget/NowPlayingWidgetViews.swift @@ -541,15 +541,23 @@ struct LargeContent: View { .foregroundStyle(.white.opacity(0.3)) .frame(width: 14, alignment: .center) - // Mini art placeholder (no per-track art data in widget) - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(colors.accent.opacity(0.2)) - .frame(width: 32, height: 32) - .overlay( - Image(systemName: "music.note") - .font(.system(size: 11, weight: .light)) - .foregroundStyle(.white.opacity(0.2)) - ) + // Mini cover art — real data from queue, fallback to accent placeholder + Group { + if let data = item.coverArtData, let img = UIImage(data: data) { + Image(uiImage: img) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + ZStack { + colors.accent.opacity(0.2) + Image(systemName: "music.note") + .font(.system(size: 11, weight: .light)) + .foregroundStyle(.white.opacity(0.2)) + } + } + } + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) VStack(alignment: .leading, spacing: 1) { Text(item.title) @@ -587,17 +595,6 @@ struct LargeContent: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 2) - } else if !entry.isPlaying { - HStack(spacing: 4) { - Text("⏸") - .font(.system(size: 10)) - .foregroundStyle(.white.opacity(0.4)) - Text("Paused") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(colors.accent.opacity(0.7)) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 2) } } } diff --git a/iOS/Data/WidgetBridge.swift b/iOS/Data/WidgetBridge.swift index 7fcc253..2d80cea 100644 --- a/iOS/Data/WidgetBridge.swift +++ b/iOS/Data/WidgetBridge.swift @@ -74,7 +74,7 @@ final class WidgetBridge { duration: TimeInterval, coverArtId: String?, coverArtImage: UIImage?, - queue: [(title: String, artist: String)], + queue: [(title: String, artist: String, coverData: Data?)], waveformSamples: [Float]? = nil, crossfadeAt: TimeInterval? = nil ) { @@ -91,7 +91,9 @@ final class WidgetBridge { cachedSecondaryHex = secondary } - let queueItems = queue.prefix(3).map { WidgetQueueItem(title: $0.title, artist: $0.artist) } + let queueItems = queue.prefix(3).map { + WidgetQueueItem(title: $0.title, artist: $0.artist, coverArtData: $0.coverData) + } state.pushState( title: title, diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index ca4a307..7334778 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -263,7 +263,7 @@ actor CompanionAPIService { /// Call once on app launch to populate SmartDJCache.bulkImport(). func fetchAllProfiles() async throws -> [String: SmartDJProfile] { let base = try baseURL() - let url = base.appendingPathComponent("smart-dj/profiles/export") + let url = URL(string: "\(base)/smart-dj/profiles/export")! let (data, response) = try await session.data(from: url) try validateResponse(response) @@ -382,7 +382,7 @@ actor CompanionAPIService { guard !paths.isEmpty else { return [:] } let base = try baseURL() - var components = URLComponents(url: base.appendingPathComponent("smart-dj/bulk-profiles"), resolvingAgainstBaseURL: false)! + var components = URLComponents(string: "\(base)/smart-dj/bulk-profiles")! components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))] guard let url = components.url else { throw CompanionError.invalidURL } @@ -417,7 +417,7 @@ actor CompanionAPIService { /// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration). func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse { let base = try baseURL() - var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)! + var components = URLComponents(string: "\(base)/lyrics/fetch")! components.queryItems = [ URLQueryItem(name: "artist", value: artist), URLQueryItem(name: "title", value: title), @@ -432,7 +432,7 @@ actor CompanionAPIService { /// Search LRCLIB for lyrics matching a query. func searchLyrics(query: String) async throws -> [LRCLIBResult] { let base = try baseURL() - var components = URLComponents(url: base.appendingPathComponent("lyrics/search"), resolvingAgainstBaseURL: false)! + var components = URLComponents(string: "\(base)/lyrics/search")! components.queryItems = [URLQueryItem(name: "q", value: query)] guard let url = components.url else { throw CompanionError.invalidURL } let (data, response) = try await session.data(from: url) @@ -443,7 +443,7 @@ actor CompanionAPIService { /// Embed LRC lyrics into an audio file via Companion API. func embedLyrics(relativePath: String, lrcContent: String) async throws { let base = try baseURL() - let url = base.appendingPathComponent("lyrics/embed") + let url = URL(string: "\(base)/lyrics/embed")! var request = URLRequest(url: url) request.httpMethod = "POST" diff --git a/iOS/Views/Companion/SmartCrossfadeManager.swift b/iOS/Views/Companion/SmartCrossfadeManager.swift index e3e05a2..7f376e2 100644 --- a/iOS/Views/Companion/SmartCrossfadeManager.swift +++ b/iOS/Views/Companion/SmartCrossfadeManager.swift @@ -86,6 +86,8 @@ class SmartCrossfadeManager: ObservableObject { var needsNextTrack: (() -> Void)? var timeUpdate: ((TimeInterval, TimeInterval) -> Void)? var visualizerHandoff: ((AVPlayer) -> Void)? + /// Fires at crossfade midpoint — AudioPlayer updates currentSong, artwork, colors. + var songHandoff: ((Song) -> Void)? private init() { isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled") @@ -342,10 +344,10 @@ class SmartCrossfadeManager: ObservableObject { timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self else { return } - // During crossfade past midpoint, report from the INCOMING player - // so the seek bar shows the new track's position, not -0:00 + // During crossfade, report from the INCOMING player immediately. + // The user hears the new song fading in — seek bar should match. let reportPlayer: AVPlayer - if self.isCrossfading && self.didHandoffVisualizer { + if self.isCrossfading { reportPlayer = self.standbyPlayer } else { reportPlayer = self.activePlayer @@ -444,6 +446,11 @@ class SmartCrossfadeManager: ObservableObject { if progress >= 0.5 && !didHandoffVisualizer { didHandoffVisualizer = true visualizerHandoff?(standbyPlayer) + // Transition metadata/artwork/colors at the midpoint so the UI + // reflects the incoming song while the audio is still fading. + if let incoming = pendingNextSong { + songHandoff?(incoming) + } } if progress >= 1.0 { diff --git a/iOS/Views/Lyrics/LyricsManager.swift b/iOS/Views/Lyrics/LyricsManager.swift index 43e2cb6..51a44e9 100644 --- a/iOS/Views/Lyrics/LyricsManager.swift +++ b/iOS/Views/Lyrics/LyricsManager.swift @@ -180,59 +180,63 @@ final class LyricsManager: ObservableObject { // MARK: - Private: Fetch Pipeline private func fetchLyrics(artist: String, title: String, path: String?, duration: Double) async { - // 1. Try Companion API /lyrics/get (checks embedded + .lrc + cache) + let service = LyricsService.shared + + // 1. On-device: read embedded lyrics from downloaded file + if let songId = currentSongId { + if let response = await service.readEmbedded(songId: songId) { + if let synced = response.syncedLyrics, !synced.isEmpty { + let lines = LRCParser.parse(synced) + let data = LyricsData(lines: lines, source: "embedded", hasSynced: true) + await MainActor.run { self.currentLyrics = data; self.isLoading = false } + LyricsCache.shared.store(data, artist: artist, title: title) + DebugLogger.shared.log("Lyrics: embedded in local file (\(lines.count) lines)", category: "Lyrics") + return + } else if let plain = response.plainLyrics, !plain.isEmpty { + await MainActor.run { self.importPlain(plain, source: "embedded"); self.isLoading = false } + DebugLogger.shared.log("Lyrics: plain text embedded in local file", category: "Lyrics") + return + } + } + } + + // 2. LRCLIB direct from iOS (no companion needed) + do { + let response = try await service.fetch(artist: artist, title: title, duration: duration) + if let synced = response.syncedLyrics, !synced.isEmpty { + let lines = LRCParser.parse(synced) + let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true) + await MainActor.run { self.currentLyrics = data; self.isLoading = false } + LyricsCache.shared.store(data, artist: artist, title: title) + DebugLogger.shared.log("Lyrics: LRCLIB direct (synced, \(lines.count) lines)", category: "Lyrics") + return + } else if let plain = response.plainLyrics, !plain.isEmpty { + await MainActor.run { self.importPlain(plain, source: "lrclib"); self.isLoading = false } + DebugLogger.shared.log("Lyrics: LRCLIB direct (plain text)", category: "Lyrics") + return + } + } catch { + DebugLogger.shared.log("Lyrics: LRCLIB direct failed: \(error.localizedDescription)", category: "Lyrics") + } + + // 3. Companion API fallback (reads server-side embedded tags + .lrc sidecars) if CompanionSettings.shared.isEnabled, let path = path { do { let api = CompanionAPIService.shared let response = try await api.fetchLyricsForPath(relativePath: path) if let synced = response.syncedLyrics, !synced.isEmpty { let lines = LRCParser.parse(synced) - let data = LyricsData(lines: lines, source: response.source ?? "embedded", hasSynced: true) - await MainActor.run { - self.currentLyrics = data - self.isLoading = false - } + let data = LyricsData(lines: lines, source: response.source ?? "companion", hasSynced: true) + await MainActor.run { self.currentLyrics = data; self.isLoading = false } LyricsCache.shared.store(data, artist: artist, title: title) - DebugLogger.shared.log("Lyrics: found via companion (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics") + DebugLogger.shared.log("Lyrics: companion fallback (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics") return } else if let plain = response.plainLyrics, !plain.isEmpty { - await MainActor.run { - self.importPlain(plain, source: response.source ?? "embedded") - self.isLoading = false - } - DebugLogger.shared.log("Lyrics: plain text via companion (\(response.source ?? "?"))", category: "Lyrics") + await MainActor.run { self.importPlain(plain, source: response.source ?? "companion"); self.isLoading = false } return } } catch { - DebugLogger.shared.log("Lyrics: companion fetch failed: \(error.localizedDescription)", category: "Lyrics") - } - } - - // 2. Try LRCLIB via Companion API - if CompanionSettings.shared.isEnabled { - do { - let api = CompanionAPIService.shared - let response = try await api.fetchLyricsFromLRCLIB(artist: artist, title: title, duration: duration) - if let synced = response.syncedLyrics, !synced.isEmpty { - let lines = LRCParser.parse(synced) - let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true) - await MainActor.run { - self.currentLyrics = data - self.isLoading = false - } - LyricsCache.shared.store(data, artist: artist, title: title) - DebugLogger.shared.log("Lyrics: found on LRCLIB (synced, \(lines.count) lines)", category: "Lyrics") - return - } else if let plain = response.plainLyrics, !plain.isEmpty { - await MainActor.run { - self.importPlain(plain, source: "lrclib") - self.isLoading = false - } - DebugLogger.shared.log("Lyrics: found on LRCLIB (plain text)", category: "Lyrics") - return - } - } catch { - DebugLogger.shared.log("Lyrics: LRCLIB fetch failed: \(error.localizedDescription)", category: "Lyrics") + DebugLogger.shared.log("Lyrics: companion fallback failed: \(error.localizedDescription)", category: "Lyrics") } } diff --git a/iOS/Views/Lyrics/LyricsSearchSheet.swift b/iOS/Views/Lyrics/LyricsSearchSheet.swift index 5aac21a..712fdc6 100644 --- a/iOS/Views/Lyrics/LyricsSearchSheet.swift +++ b/iOS/Views/Lyrics/LyricsSearchSheet.swift @@ -188,8 +188,7 @@ struct LyricsSearchSheet: View { Task { do { - let api = CompanionAPIService.shared - let fetched = try await api.searchLyrics(query: query) + let fetched = try await LyricsService.shared.search(query: query) await MainActor.run { results = fetched isSearching = false diff --git a/iOS/Views/Lyrics/LyricsService.swift b/iOS/Views/Lyrics/LyricsService.swift new file mode 100644 index 0000000..0b4fa89 --- /dev/null +++ b/iOS/Views/Lyrics/LyricsService.swift @@ -0,0 +1,178 @@ +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 + ) + } +}