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:
Dallas Groot 2026-04-14 00:27:10 -07:00
parent 2a0b3d8c47
commit cea4e3868e
10 changed files with 326 additions and 94 deletions

View file

@ -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() {

View file

@ -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.

View file

@ -179,5 +179,6 @@ struct NowPlayingWidget: Widget {
.configurationDisplayName("Now Playing")
.description("Control playback and see what's playing.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}

View file

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

View file

@ -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,

View file

@ -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"

View file

@ -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 {

View file

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

View file

@ -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

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