diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt new file mode 100644 index 0000000..57a7d34 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt @@ -0,0 +1,89 @@ +package com.novasonic.app.api + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.android.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class LrcLibResponse( + val id: Long = 0, + val trackName: String = "", + val artistName: String = "", + val albumName: String = "", + val duration: Double = 0.0, + val instrumental: Boolean = false, + val plainLyrics: String? = null, + val syncedLyrics: String? = null, +) + +data class LrcLine(val timeMs: Long, val text: String) + +fun parseLrc(lrc: String): List { + val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""") + return lrc.lines() + .mapNotNull { line -> + regex.find(line.trim())?.let { m -> + val mm = m.groupValues[1].toLong() + val ss = m.groupValues[2].toLong() + val frac = m.groupValues[3] + val ms = mm * 60_000 + ss * 1_000 + + if (frac.length == 2) frac.toLong() * 10 else frac.toLong() + val text = m.groupValues[4].trim() + if (text.isNotEmpty()) LrcLine(ms, text) else null + } + } + .sortedBy { it.timeMs } +} + +object LrcLibClient { + private val client = HttpClient(Android) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true; isLenient = true }) + } + } + + suspend fun getLyrics( + artist: String, + title: String, + album: String?, + durationSecs: Int?, + ): LrcLibResponse? { + // 1. Exact match — fastest, most accurate when metadata is clean + val exact = runCatching { + client.get("https://lrclib.net/api/get") { + parameter("artist_name", artist) + parameter("track_name", title) + if (!album.isNullOrBlank()) parameter("album_name", album) + if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs) + }.body() + }.getOrNull() + if (exact != null) return exact + + // 2. Fuzzy search fallback — handles artist name mismatches (e.g. "Utada" vs "Hikaru Utada") + val candidates = runCatching { + client.get("https://lrclib.net/api/search") { + parameter("track_name", title) + if (artist.isNotBlank()) parameter("artist_name", artist) + }.body>() + }.getOrNull() ?: return null + + if (candidates.isEmpty()) return null + + return if (durationSecs != null && durationSecs > 0) { + val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() } + val pool = synced.ifEmpty { candidates } + pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) } + ?.takeIf { kotlin.math.abs(it.duration - durationSecs) <= 5.0 } + ?: synced.firstOrNull() + ?: candidates.firstOrNull() + } else { + candidates.firstOrNull { !it.syncedLyrics.isNullOrBlank() } + ?: candidates.firstOrNull() + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt index 3dd8179..43245c5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt @@ -157,10 +157,6 @@ class SubsonicClient( get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song ?: emptyList() - suspend fun getLyrics(songId: String): List = - get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics - ?: emptyList() - suspend fun addSongToPlaylist(playlistId: String, songId: String) = getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId)) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt index ff16ac5..8316bee 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt @@ -33,7 +33,6 @@ data class SubsonicResponse( val topSongs: SongListResult? = null, val genres: GenresResult? = null, val songsByGenre: SongListResult? = null, - val lyricsList: LyricsListResult? = null, ) @Serializable @@ -184,19 +183,3 @@ data class Genre( @Serializable data class GenresResult(val genre: List = emptyList()) -// ── Lyrics ──────────────────────────────────────────────────────────────────── -@Serializable -data class LyricsLine(val start: Long? = null, val value: String = "") - -@Serializable -data class StructuredLyrics( - val lang: String = "", - val synced: Boolean = false, - val line: List = emptyList(), - val displayArtist: String? = null, - val displayTitle: String? = null, - val offset: Long? = null, -) - -@Serializable -data class LyricsListResult(val structuredLyrics: List = emptyList()) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 9fb54db..861304d 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -56,7 +56,6 @@ class MusicRepository(private val context: Context) { suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName) suspend fun getGenres() = api().getGenres() suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre) - suspend fun getLyrics(songId: String) = api().getLyrics(songId) suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId) suspend fun createPlaylist(name: String, songIds: List) = api().createPlaylist(name, songIds) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 42ed285..72a5cdd 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -386,7 +386,7 @@ fun NowPlayingScreen( // Center of active line sits at 30% from top (clearly upper area) val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f - lyricsViewH * 0.30f).coerceAtLeast(0f) - lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing)) + lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing)) } when { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt index 0690f0c..325879d 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt @@ -1,12 +1,12 @@ -package com.novasonic.app.viewmodel +package com.novasonic.app.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.novasonic.app.api.LrcLibClient import com.novasonic.app.api.LrcLine +import com.novasonic.app.api.parseLrc import com.novasonic.app.api.model.Song -import com.novasonic.app.app -import com.novasonic.app.data.MusicRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -16,12 +16,11 @@ data class LyricsUiState( val plainText: String = "", // fallback plain lyrics val isLoading: Boolean = false, val notFound: Boolean = false, - val songId: String = "", // which song these are for + val songId: String = "", ) class LyricsViewModel(app: Application) : AndroidViewModel(app) { - private val repo = MusicRepository(app.applicationContext) private val _state = MutableStateFlow(LyricsUiState()) val state: StateFlow = _state @@ -29,31 +28,19 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) { if (_state.value.songId == song.id && !_state.value.isLoading) return _state.value = LyricsUiState(isLoading = true, songId = song.id) viewModelScope.launch { - // Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching - val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull() - val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() } - val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() } - - when { - synced != null -> { - val lines = synced.line - .mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } } - .filter { it.text.isNotBlank() } - .sortedBy { it.timeMs } - _state.value = if (lines.isNotEmpty()) - LyricsUiState(lines = lines, songId = song.id) - else - LyricsUiState(notFound = true, songId = song.id) - } - plain != null -> { - val text = plain.line.joinToString("\n") { it.value }.trim() - _state.value = if (text.isNotBlank()) - LyricsUiState(plainText = text, songId = song.id) - else - LyricsUiState(notFound = true, songId = song.id) - } - else -> _state.value = LyricsUiState(notFound = true, songId = song.id) + val result = LrcLibClient.getLyrics( + artist = song.artist ?: "", + title = song.title, + album = song.album, + durationSecs = song.duration, + ) + _state.value = when { + result == null -> LyricsUiState(notFound = true, songId = song.id) + result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id) + !result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id) + !result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id) + else -> LyricsUiState(notFound = true, songId = song.id) } } } -} \ No newline at end of file +} diff --git a/src/player/nowplaying.js b/src/player/nowplaying.js index 2c4ab20..ec28f2d 100644 --- a/src/player/nowplaying.js +++ b/src/player/nowplaying.js @@ -147,25 +147,53 @@ async function _loadNpLyrics(songId) { content.innerHTML = '
Lade Lyrics…
'; const token = ++_lyricsLoadToken; - const { getLyricsBySongId } = await import('../api.js'); + const s = S.queue[S.qIdx]; + const artist = (s?.artist || '').split(/[/,]/)[0].trim(); + const title = s?.title || ''; + const album = s?.album || ''; + const dur = s?.duration || 0; + + // 1. Exact match (fast path) + let result = null; + try { + const p = new URLSearchParams({ artist_name: artist, track_name: title }); + if(album) p.set('album_name', album); + if(dur) p.set('duration', dur); + const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(8000) }); + if(r.ok) result = await r.json(); + } catch {} + + // 2. Search fallback — handles metadata mismatches (e.g. "Utada" vs "Hikaru Utada") if(token !== _lyricsLoadToken) return; - - // Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching - const lyrics = await getLyricsBySongId(songId); - if(token !== _lyricsLoadToken) return; - - content.innerHTML = ''; - const synced = lyrics.find(t => t.synced && t.line?.length); - const plain = lyrics.find(t => t.line?.length); - - if(synced) { - _renderSyncedLines(content, synced.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value)); - return; + if(!result?.syncedLyrics && !result?.plainLyrics) { + try { + const p = new URLSearchParams({ track_name: title }); + if(artist) p.set('artist_name', artist); + const r = await fetch(`https://lrclib.net/api/search?${p}`, { signal: AbortSignal.timeout(8000) }); + if(r.ok) { + const candidates = await r.json(); + if(candidates?.length) { + const synced = candidates.filter(c => c.syncedLyrics); + const pool = synced.length ? synced : candidates; + result = dur + ? pool.sort((a, b) => Math.abs(a.duration - dur) - Math.abs(b.duration - dur))[0] + : pool[0]; + } + } + } catch {} } - if(plain) { + + if(token !== _lyricsLoadToken) return; + content.innerHTML = ''; + + if(result?.syncedLyrics) { + const parsed = _parseLrc(result.syncedLyrics); + if(parsed.length) { _renderSyncedLines(content, parsed); return; } + } + if(result?.plainLyrics) { const el = document.createElement('div'); el.className = 'lyrics-plain'; - el.textContent = plain.line.map(l => l.value).join('\n'); + el.textContent = result.plainLyrics; content.appendChild(el); return; }