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.
This commit is contained in:
parent
2a0b3d8c47
commit
cea4e3868e
10 changed files with 326 additions and 94 deletions
|
|
@ -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
|
||||
// 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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -179,5 +179,6 @@ struct NowPlayingWidget: Widget {
|
|||
.configurationDisplayName("Now Playing")
|
||||
.description("Control playback and see what's playing.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
178
iOS/Views/Lyrics/LyricsService.swift
Normal file
178
iOS/Views/Lyrics/LyricsService.swift
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue