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 nextSong = SmartCrossfadeManager.shared.pendingNextSong,
|
||||||
let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong)
|
let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong)
|
||||||
else { return }
|
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)
|
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)
|
crossfade.playSong(song, url: url)
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
|
|
@ -1109,20 +1135,20 @@ class AudioPlayer: NSObject, ObservableObject {
|
||||||
crossfade.needsNextTrack = { [weak self] in
|
crossfade.needsNextTrack = { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Scrobble the song that just finished.
|
// Scrobble + currentSong + artwork already handled by songHandoff at midpoint.
|
||||||
// playerDidFinish only fires on natural item end — crossfade replaces the item
|
// Here we only handle post-swap housekeeping.
|
||||||
// before that happens, so we scrobble here instead.
|
|
||||||
if let finishedSong = self.currentSong {
|
|
||||||
Task { try? await ServerManager.shared.client.scrobble(id: finishedSong.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync queueIndex to whatever was actually in standby (pendingNextSong),
|
// Ensure queueIndex is synced (songHandoff may have set it, but verify)
|
||||||
// not just queueIndex+1. The queue may have changed between prepareNext()
|
|
||||||
// and now — this guarantees currentSong matches what the active player is playing.
|
|
||||||
if let nowPlaying = crossfade.pendingNextSong,
|
if let nowPlaying = crossfade.pendingNextSong,
|
||||||
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
|
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
|
||||||
self.queueIndex = idx
|
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 {
|
} else {
|
||||||
// Fallback: queue was cleared or song removed — advance sequentially
|
// Fallback: queue was cleared or song removed — advance sequentially
|
||||||
let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count
|
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.queueIndex = min(self.queueIndex + 1, self.queue.count - 1)
|
||||||
}
|
}
|
||||||
self.currentSong = self.queue[self.queueIndex]
|
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(
|
PlaybackStateStore.shared.save(
|
||||||
queue: self.queue, index: self.queueIndex,
|
queue: self.queue, index: self.queueIndex,
|
||||||
currentTime: 0, currentSongId: self.currentSong?.id
|
currentTime: 0, currentSongId: self.currentSong?.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Swap AudioPlayer.player to the new active crossfade player
|
||||||
self.player = crossfade.activePlayer
|
self.player = crossfade.activePlayer
|
||||||
|
|
||||||
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer on the
|
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
|
||||||
// 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.
|
|
||||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||||
if let newItem = crossfade.activePlayer.currentItem {
|
if let newItem = crossfade.activePlayer.currentItem {
|
||||||
NotificationCenter.default.addObserver(
|
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)
|
// Prepare next-next (picks up any queue changes that happened mid-fade)
|
||||||
self.prepareNextForCrossfade()
|
self.prepareNextForCrossfade()
|
||||||
}
|
}
|
||||||
|
|
@ -1798,8 +1821,21 @@ class AudioPlayer: NSObject, ObservableObject {
|
||||||
duration: duration
|
duration: duration
|
||||||
)
|
)
|
||||||
|
|
||||||
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||||||
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
|
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).
|
// Cover art: custom first, then server (memory + disk).
|
||||||
var coverImage: UIImage?
|
var coverImage: UIImage?
|
||||||
|
|
@ -1926,10 +1962,17 @@ class AudioPlayer: NSObject, ObservableObject {
|
||||||
SmartCrossfadeManager.shared.stop()
|
SmartCrossfadeManager.shared.stop()
|
||||||
isUsingCrossfade = false
|
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
|
#endif
|
||||||
stopEngine()
|
stopEngine()
|
||||||
stopAVPlayer()
|
stopAVPlayer()
|
||||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
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() {
|
func stop() {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import Foundation
|
||||||
struct WidgetQueueItem: Codable, Equatable {
|
struct WidgetQueueItem: Codable, Equatable {
|
||||||
let title: String
|
let title: String
|
||||||
let artist: String
|
let artist: String
|
||||||
|
let coverArtData: Data? // small JPEG thumbnail (~40×40 → ~2KB each)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commands the widget can send to the main app.
|
/// Commands the widget can send to the main app.
|
||||||
|
|
|
||||||
|
|
@ -179,5 +179,6 @@ struct NowPlayingWidget: Widget {
|
||||||
.configurationDisplayName("Now Playing")
|
.configurationDisplayName("Now Playing")
|
||||||
.description("Control playback and see what's playing.")
|
.description("Control playback and see what's playing.")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
|
.contentMarginsDisabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -541,15 +541,23 @@ struct LargeContent: View {
|
||||||
.foregroundStyle(.white.opacity(0.3))
|
.foregroundStyle(.white.opacity(0.3))
|
||||||
.frame(width: 14, alignment: .center)
|
.frame(width: 14, alignment: .center)
|
||||||
|
|
||||||
// Mini art placeholder (no per-track art data in widget)
|
// Mini cover art — real data from queue, fallback to accent placeholder
|
||||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
Group {
|
||||||
.fill(colors.accent.opacity(0.2))
|
if let data = item.coverArtData, let img = UIImage(data: data) {
|
||||||
.frame(width: 32, height: 32)
|
Image(uiImage: img)
|
||||||
.overlay(
|
.resizable()
|
||||||
Image(systemName: "music.note")
|
.aspectRatio(contentMode: .fill)
|
||||||
.font(.system(size: 11, weight: .light))
|
} else {
|
||||||
.foregroundStyle(.white.opacity(0.2))
|
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) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
|
|
@ -587,17 +595,6 @@ struct LargeContent: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.leading, 2)
|
.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,
|
duration: TimeInterval,
|
||||||
coverArtId: String?,
|
coverArtId: String?,
|
||||||
coverArtImage: UIImage?,
|
coverArtImage: UIImage?,
|
||||||
queue: [(title: String, artist: String)],
|
queue: [(title: String, artist: String, coverData: Data?)],
|
||||||
waveformSamples: [Float]? = nil,
|
waveformSamples: [Float]? = nil,
|
||||||
crossfadeAt: TimeInterval? = nil
|
crossfadeAt: TimeInterval? = nil
|
||||||
) {
|
) {
|
||||||
|
|
@ -91,7 +91,9 @@ final class WidgetBridge {
|
||||||
cachedSecondaryHex = secondary
|
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(
|
state.pushState(
|
||||||
title: title,
|
title: title,
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ actor CompanionAPIService {
|
||||||
/// Call once on app launch to populate SmartDJCache.bulkImport().
|
/// Call once on app launch to populate SmartDJCache.bulkImport().
|
||||||
func fetchAllProfiles() async throws -> [String: SmartDJProfile] {
|
func fetchAllProfiles() async throws -> [String: SmartDJProfile] {
|
||||||
let base = try baseURL()
|
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)
|
let (data, response) = try await session.data(from: url)
|
||||||
try validateResponse(response)
|
try validateResponse(response)
|
||||||
|
|
||||||
|
|
@ -382,7 +382,7 @@ actor CompanionAPIService {
|
||||||
guard !paths.isEmpty else { return [:] }
|
guard !paths.isEmpty else { return [:] }
|
||||||
|
|
||||||
let base = try baseURL()
|
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: ","))]
|
components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))]
|
||||||
|
|
||||||
guard let url = components.url else { throw CompanionError.invalidURL }
|
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).
|
/// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration).
|
||||||
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
||||||
let base = try baseURL()
|
let base = try baseURL()
|
||||||
var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)!
|
var components = URLComponents(string: "\(base)/lyrics/fetch")!
|
||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "artist", value: artist),
|
URLQueryItem(name: "artist", value: artist),
|
||||||
URLQueryItem(name: "title", value: title),
|
URLQueryItem(name: "title", value: title),
|
||||||
|
|
@ -432,7 +432,7 @@ actor CompanionAPIService {
|
||||||
/// Search LRCLIB for lyrics matching a query.
|
/// Search LRCLIB for lyrics matching a query.
|
||||||
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
|
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
|
||||||
let base = try baseURL()
|
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)]
|
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||||
guard let url = components.url else { throw CompanionError.invalidURL }
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
||||||
let (data, response) = try await session.data(from: url)
|
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.
|
/// Embed LRC lyrics into an audio file via Companion API.
|
||||||
func embedLyrics(relativePath: String, lrcContent: String) async throws {
|
func embedLyrics(relativePath: String, lrcContent: String) async throws {
|
||||||
let base = try baseURL()
|
let base = try baseURL()
|
||||||
let url = base.appendingPathComponent("lyrics/embed")
|
let url = URL(string: "\(base)/lyrics/embed")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ class SmartCrossfadeManager: ObservableObject {
|
||||||
var needsNextTrack: (() -> Void)?
|
var needsNextTrack: (() -> Void)?
|
||||||
var timeUpdate: ((TimeInterval, TimeInterval) -> Void)?
|
var timeUpdate: ((TimeInterval, TimeInterval) -> Void)?
|
||||||
var visualizerHandoff: ((AVPlayer) -> Void)?
|
var visualizerHandoff: ((AVPlayer) -> Void)?
|
||||||
|
/// Fires at crossfade midpoint — AudioPlayer updates currentSong, artwork, colors.
|
||||||
|
var songHandoff: ((Song) -> Void)?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled")
|
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
|
timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
// During crossfade past midpoint, report from the INCOMING player
|
// During crossfade, report from the INCOMING player immediately.
|
||||||
// so the seek bar shows the new track's position, not -0:00
|
// The user hears the new song fading in — seek bar should match.
|
||||||
let reportPlayer: AVPlayer
|
let reportPlayer: AVPlayer
|
||||||
if self.isCrossfading && self.didHandoffVisualizer {
|
if self.isCrossfading {
|
||||||
reportPlayer = self.standbyPlayer
|
reportPlayer = self.standbyPlayer
|
||||||
} else {
|
} else {
|
||||||
reportPlayer = self.activePlayer
|
reportPlayer = self.activePlayer
|
||||||
|
|
@ -444,6 +446,11 @@ class SmartCrossfadeManager: ObservableObject {
|
||||||
if progress >= 0.5 && !didHandoffVisualizer {
|
if progress >= 0.5 && !didHandoffVisualizer {
|
||||||
didHandoffVisualizer = true
|
didHandoffVisualizer = true
|
||||||
visualizerHandoff?(standbyPlayer)
|
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 {
|
if progress >= 1.0 {
|
||||||
|
|
|
||||||
|
|
@ -180,59 +180,63 @@ final class LyricsManager: ObservableObject {
|
||||||
// MARK: - Private: Fetch Pipeline
|
// MARK: - Private: Fetch Pipeline
|
||||||
|
|
||||||
private func fetchLyrics(artist: String, title: String, path: String?, duration: Double) async {
|
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 {
|
if CompanionSettings.shared.isEnabled, let path = path {
|
||||||
do {
|
do {
|
||||||
let api = CompanionAPIService.shared
|
let api = CompanionAPIService.shared
|
||||||
let response = try await api.fetchLyricsForPath(relativePath: path)
|
let response = try await api.fetchLyricsForPath(relativePath: path)
|
||||||
if let synced = response.syncedLyrics, !synced.isEmpty {
|
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||||
let lines = LRCParser.parse(synced)
|
let lines = LRCParser.parse(synced)
|
||||||
let data = LyricsData(lines: lines, source: response.source ?? "embedded", hasSynced: true)
|
let data = LyricsData(lines: lines, source: response.source ?? "companion", hasSynced: true)
|
||||||
await MainActor.run {
|
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
||||||
self.currentLyrics = data
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
LyricsCache.shared.store(data, artist: artist, title: title)
|
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
|
return
|
||||||
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||||
await MainActor.run {
|
await MainActor.run { self.importPlain(plain, source: response.source ?? "companion"); self.isLoading = false }
|
||||||
self.importPlain(plain, source: response.source ?? "embedded")
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
DebugLogger.shared.log("Lyrics: plain text via companion (\(response.source ?? "?"))", category: "Lyrics")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
DebugLogger.shared.log("Lyrics: companion fetch failed: \(error.localizedDescription)", category: "Lyrics")
|
DebugLogger.shared.log("Lyrics: companion fallback 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,8 +188,7 @@ struct LyricsSearchSheet: View {
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let api = CompanionAPIService.shared
|
let fetched = try await LyricsService.shared.search(query: query)
|
||||||
let fetched = try await api.searchLyrics(query: query)
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
results = fetched
|
results = fetched
|
||||||
isSearching = false
|
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