From f218cb95a4dfcb74faf4a3273ad1295cbe4b62f5 Mon Sep 17 00:00:00 2001 From: DynamycSound Date: Thu, 16 Jul 2026 14:18:24 +0000 Subject: [PATCH] feat: LyricsPlus/BetterLyrics providers, provider order, embedded lyrics detection, batch UI overhaul - Add LyricsPlus provider (issue #4): 6 community mirrors, word-by-word (enhanced LRC) and line-synced output, remembers the last working mirror - Add BetterLyrics provider (issue #4): Apple-style TTML from lyrics-api.boidu.dev parsed by a new pure-Kotlin TTML parser with syllable merging, agents and background vocals - Both are direct-fetch providers wired into the smart matcher with a duration-verified confidence cap, and into the single-song/quick-search flows - Fix embedded-lyrics detection (issue #5): the home page scan now reads lyrics embedded in the audio tags (memoised TagLib reader), so embedded-only tracks no longer show as missing; batch skip-existing honours embedded synced lyrics too - User-configurable provider fallback chain (issue #6): new Settings section to reorder/disable providers; batch and single-song searches walk that exact chain - Batch download options are now a full page with a Start button instead of a popup dialog - Synced/Unsynced/Not found/Failed counters on the batch progress screen are tappable and open a half-screen drawer (draggable to full) listing the affected songs; tapping a song previews its lyrics Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012b5krdonsAyKdwbAjhFxP8 --- .../songsync/data/UserSettingsController.kt | 43 ++++ .../lyrics_providers/LyricsProviderService.kt | 17 ++ .../lyrics_providers/SmartLyricsMatcher.kt | 45 ++++ .../others/BetterLyricsAPI.kt | 48 ++++ .../lyrics_providers/others/LyricsPlusAPI.kt | 131 ++++++++++ .../lyrics_providers/others/LyricsPlus.kt | 49 ++++ .../java/pl/lambada/songsync/ui/Navigator.kt | 22 ++ .../ui/screens/batch/BatchOptionsScreen.kt | 211 ++++++++++++++++ .../ui/screens/batch/BatchProgressScreen.kt | 174 ++++++++++++- .../songsync/ui/screens/home/HomeScreen.kt | 19 +- .../songsync/ui/screens/home/HomeViewModel.kt | 10 +- .../home/components/BatchDownloadLyrics.kt | 78 ------ .../BatchDownloadWarningDialog.kt | 169 ------------- .../lyricsFetch/LyricsFetchViewModel.kt | 6 +- .../ui/screens/settings/SettingsScreen.kt | 22 ++ .../components/ProviderOrderSection.kt | 86 +++++++ .../lambada/songsync/util/EmbeddedLyrics.kt | 59 +++++ .../pl/lambada/songsync/util/LyricsUtils.kt | 59 +++-- .../lambada/songsync/util/TtmlLyricsParser.kt | 230 ++++++++++++++++++ .../songsync/util/matching/LrcPrescan.kt | 39 ++- app/src/main/res/values/strings.xml | 23 +- .../matching/EmbeddedLyricsPrescanTest.kt | 80 ++++++ .../songsync/util/ProviderOrderParsingTest.kt | 40 +++ .../songsync/util/TtmlLyricsParserTest.kt | 104 ++++++++ 24 files changed, 1460 insertions(+), 304 deletions(-) create mode 100644 app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/BetterLyricsAPI.kt create mode 100644 app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LyricsPlusAPI.kt create mode 100644 app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/LyricsPlus.kt create mode 100644 app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt delete mode 100644 app/src/main/java/pl/lambada/songsync/ui/screens/home/components/BatchDownloadLyrics.kt delete mode 100644 app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/BatchDownloadWarningDialog.kt create mode 100644 app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ProviderOrderSection.kt create mode 100644 app/src/main/java/pl/lambada/songsync/util/EmbeddedLyrics.kt create mode 100644 app/src/main/java/pl/lambada/songsync/util/TtmlLyricsParser.kt create mode 100644 app/src/test/java/pl/lambada/songsync/matching/EmbeddedLyricsPrescanTest.kt create mode 100644 app/src/test/java/pl/lambada/songsync/util/ProviderOrderParsingTest.kt create mode 100644 app/src/test/java/pl/lambada/songsync/util/TtmlLyricsParserTest.kt diff --git a/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt b/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt index 5abccee..f192f4d 100644 --- a/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt +++ b/app/src/main/java/pl/lambada/songsync/data/UserSettingsController.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking import pl.lambada.songsync.domain.model.SortOrders import pl.lambada.songsync.domain.model.SortValues import pl.lambada.songsync.util.Providers +import pl.lambada.songsync.util.defaultProviderFallbackOrder import pl.lambada.songsync.util.set /** @@ -23,6 +24,23 @@ import pl.lambada.songsync.util.set internal fun parseBlacklistedFolders(raw: String): List = raw.split(",").filter { it.isNotBlank() } +/** + * Parses the persisted provider fallback order (comma-joined enum names) back into a full provider list. + * Robust against renames/removals (unknown names are dropped) and additions (providers missing from the stored + * value — e.g. ones added in an update — are appended in default-order position). Pure + internal so it's + * unit-testable without a DataStore. + */ +internal fun parseProviderOrder(raw: String): List { + val stored = raw.split(",").mapNotNull { name -> + Providers.entries.find { it.name == name.trim() } + }.distinct() + return stored + defaultProviderFallbackOrder.filter { it !in stored } +} + +/** Parses the persisted disabled-provider set (comma-joined enum names), dropping unknown names. */ +internal fun parseDisabledProviders(raw: String): Set = + raw.split(",").mapNotNull { name -> Providers.entries.find { it.name == name.trim() } }.toSet() + class UserSettingsController(private val dataStore: DataStore) { // Read the ENTIRE preferences snapshot once at construction (a single blocking read) instead of one // runBlocking { data.first() } per field. UserSettingsController is built on the main thread in @@ -51,6 +69,18 @@ class UserSettingsController(private val dataStore: DataStore) { ) private set + // Provider fallback chain (issue #6): the full, user-arrangeable order providers are tried in, plus the set + // the user switched off. The effective chain is [enabledProviderOrder]. + var providerOrder by mutableStateOf(parseProviderOrder(prefs[providerOrderKey] ?: "")) + private set + + var disabledProviders by mutableStateOf(parseDisabledProviders(prefs[disabledProvidersKey] ?: "")) + private set + + /** The providers actually tried, in the user's order. Never empty: falls back to LRCLib if all are off. */ + val enabledProviderOrder: List + get() = providerOrder.filterNot { it in disabledProviders }.ifEmpty { listOf(Providers.LRCLIB) } + var hideLyrics by mutableStateOf(prefs[hideLyricsKey] ?: false) private set @@ -136,6 +166,17 @@ class UserSettingsController(private val dataStore: DataStore) { blacklistedFolders = to } + fun updateProviderOrder(to: List) { + dataStore.set(providerOrderKey, to.joinToString(",") { it.name }) + providerOrder = to + } + + fun updateProviderEnabled(provider: Providers, enabled: Boolean) { + val to = if (enabled) disabledProviders - provider else disabledProviders + provider + dataStore.set(disabledProvidersKey, to.joinToString(",") { it.name }) + disabledProviders = to + } + fun updateHideLyrics(to: Boolean) { dataStore.set(hideLyricsKey, to) hideLyrics = to @@ -231,6 +272,8 @@ private val embedKey = booleanPreferencesKey("embed_lyrics") private val passedInitKey = booleanPreferencesKey("passed_init") private val selectedProviderKey = stringPreferencesKey("provider") private val blacklistedFoldersKey = stringPreferencesKey("blacklist") +private val providerOrderKey = stringPreferencesKey("provider_fallback_order") +private val disabledProvidersKey = stringPreferencesKey("disabled_providers") private val hideLyricsKey = booleanPreferencesKey("hide_lyrics") private val includeTranslationKey = booleanPreferencesKey("include_translation") private val includeRomanizationKey = booleanPreferencesKey("include_romanization") diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt index 573c0c3..b70ca1a 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt @@ -2,8 +2,10 @@ package pl.lambada.songsync.data.remote.lyrics_providers import android.util.Log import pl.lambada.songsync.data.remote.lyrics_providers.apple.AppleAPI +import pl.lambada.songsync.data.remote.lyrics_providers.others.BetterLyricsAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.LRCLibAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI +import pl.lambada.songsync.data.remote.lyrics_providers.others.LyricsPlusAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.NeteaseAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.QQMusicAPI import pl.lambada.songsync.data.remote.lyrics_providers.spotify.SpotifyAPI @@ -69,6 +71,11 @@ class LyricsProviderService { Providers.NETEASE -> NeteaseAPI().getSongInfo(query, offset) ?: throw NoTrackFoundException() Providers.QQMUSIC -> QQMusicAPI().getSongInfo(query, offset) ?: throw NoTrackFoundException() Providers.APPLE -> appleAPI.getSongInfo(query, offset) ?: throw NoTrackFoundException() + // Direct-fetch providers: no search endpoint, so there is nothing to paginate — the query itself + // is the lookup token and getSyncedLyrics performs the actual fetch. + Providers.LYRICSPLUS, Providers.BETTERLYRICS -> + if (offset > 0) throw NoTrackFoundException() + else SongInfo(songName = query.songName, artistName = query.artistName) } } catch (e: Exception) { when (e) { @@ -219,6 +226,16 @@ class LyricsProviderService { Providers.APPLE -> appleAPI.getSyncedLyrics( song.appleID ?: 0L, multiPersonWordByWord ) + + Providers.LYRICSPLUS -> LyricsPlusAPI().getSyncedLyrics( + song.songName.orEmpty(), song.artistName.orEmpty(), + multiPersonWordByWord = multiPersonWordByWord, + ) + + Providers.BETTERLYRICS -> BetterLyricsAPI().getSyncedLyrics( + song.songName.orEmpty(), song.artistName.orEmpty(), + multiPersonWordByWord = multiPersonWordByWord, + ) } } } diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt index 2a46956..d4e762e 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/SmartLyricsMatcher.kt @@ -2,8 +2,10 @@ package pl.lambada.songsync.data.remote.lyrics_providers import android.util.Log import kotlinx.coroutines.delay +import pl.lambada.songsync.data.remote.lyrics_providers.others.BetterLyricsAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.LRCLibAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI +import pl.lambada.songsync.data.remote.lyrics_providers.others.LyricsPlusAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.NeteaseAPI import pl.lambada.songsync.domain.model.SongInfo import pl.lambada.songsync.util.Providers @@ -56,6 +58,9 @@ class SmartLyricsMatcher( private val providerService: LyricsProviderService? = null, /** Internal "last resort" canonicalizer (iTunes/Deezer) — not a user-facing provider. */ private val lastResortApi: LastResortAPI = LastResortAPI(), + /** Direct-fetch providers (no search endpoint): word-by-word/TTML sources added for issue #4. */ + private val lyricsPlus: LyricsPlusAPI = LyricsPlusAPI(), + private val betterLyrics: BetterLyricsAPI = BetterLyricsAPI(), ) { /** @@ -84,6 +89,7 @@ class SmartLyricsMatcher( val found = when (provider) { Providers.LRCLIB -> searchLrcLib(query, local, cand, config, log) Providers.NETEASE -> searchNetease(query, local, cand, config, log) + Providers.LYRICSPLUS, Providers.BETTERLYRICS -> searchDirect(provider, local, cand, config, log) else -> searchGeneric(provider, local, cand, config, log) } @@ -231,6 +237,45 @@ class SmartLyricsMatcher( }?.takeIf { it.isNotBlank() } } + /** + * Direct-fetch path for LyricsPlus/BetterLyrics: these services take title/artist(/duration/album) and do + * their own matching server-side, returning lyrics or nothing — there are no search results to score. The + * returned hit therefore echoes the candidate we asked for; because that makes a perfect score + * self-fulfilling, the hit is only allowed to AUTO_ACCEPT when the local duration was sent along (the + * service verified the length) — otherwise it is capped at REVIEW like other unverifiable matches. + */ + private suspend fun searchDirect( + provider: Providers, local: LocalTrack, cand: QueryCandidate, config: MatchConfig, log: (String) -> Unit + ): List { + val artist = cand.artist?.takeIf { it.isNotBlank() } ?: local.artist ?: return emptyList() + val durationSec = local.durationSec?.toInt()?.takeIf { it > 0 } + + val lyrics = runCatching { + withRetry(config.maxRetries, onRetry = { a, d, e -> + log(" [${provider.displayName}] retry $a in ${d}ms (${e.message})") + }) { + when (provider) { + Providers.LYRICSPLUS -> lyricsPlus.getSyncedLyrics(cand.title, artist, durationSec, local.album) + else -> betterLyrics.getSyncedLyrics(cand.title, artist, durationSec, local.album) + } + } + }.onFailure { log(" [${provider.displayName}] failed: ${it.message}") }.getOrNull() + if (lyrics.isNullOrBlank()) return emptyList() + + val pr = ProviderResult( + title = cand.title, + artist = artist, + durationSec = if (durationSec != null) local.durationSec else null, + album = local.album, + hasSyncedLyrics = true, + ) + var conf = scoreFor(local, pr, cand.strategy) + if (durationSec == null && conf.tier == MatchTier.AUTO_ACCEPT) { + conf = conf.copy(score = 0.80, tier = MatchTier.REVIEW) + } + return listOf(ScoredHit(provider, cand.strategy, pr, conf, lyrics, null)) + } + /** * Scored multi-result path for providers that don't expose a full search endpoint here (Apple, Spotify, QQ). * We walk a few result offsets, score each candidate, and keep the believable ones. This fixes cases where a diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/BetterLyricsAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/BetterLyricsAPI.kt new file mode 100644 index 0000000..38b1980 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/BetterLyricsAPI.kt @@ -0,0 +1,48 @@ +package pl.lambada.songsync.data.remote.lyrics_providers.others + +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import kotlinx.serialization.Serializable +import pl.lambada.songsync.util.TtmlLyricsParser +import pl.lambada.songsync.util.networking.Ktor.client +import pl.lambada.songsync.util.networking.Ktor.json + +/** + * BetterLyrics provider (lyrics-api.boidu.dev): serves Apple-style TTML with word-level timing behind a direct + * title/artist(/duration/album) query — no search/offset pagination. The TTML body is converted to the app's + * enhanced-LRC dialect by [TtmlLyricsParser]. + */ +class BetterLyricsAPI { + private val baseURL = "https://lyrics-api.boidu.dev" + + @Serializable + private data class TtmlResponse(val ttml: String? = null) + + /** Fetches lyrics and returns them in the app's LRC dialect, or null when nothing matched. */ + suspend fun getSyncedLyrics( + title: String, + artist: String, + durationSec: Int? = null, + album: String? = null, + multiPersonWordByWord: Boolean = false, + ): String? { + if (title.isBlank() || artist.isBlank()) return null + + // Exact title/artist on purpose: the service does its own matching, and normalizing here can land on a + // different edition (radio edit vs original) whose timing won't line up with the local file. + val response = client.get("$baseURL/getLyrics") { + parameter("s", title) + parameter("a", artist) + if (durationSec != null && durationSec > 0) parameter("d", durationSec) + if (!album.isNullOrBlank()) parameter("al", album) + } + val body = response.bodyAsText(Charsets.UTF_8) + if (response.status.value !in 200..299 || body.isBlank()) return null + + val ttml = runCatching { json.decodeFromString(body).ttml }.getOrNull() + ?: return null + val lines = TtmlLyricsParser.parse(ttml) + return TtmlLyricsParser.toLrc(lines, multiPersonWordByWord) + } +} diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LyricsPlusAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LyricsPlusAPI.kt new file mode 100644 index 0000000..eb08f44 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/LyricsPlusAPI.kt @@ -0,0 +1,131 @@ +package pl.lambada.songsync.data.remote.lyrics_providers.others + +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusLine +import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusResponse +import pl.lambada.songsync.domain.model.lyrics_providers.others.LyricsPlusWord +import pl.lambada.songsync.util.ext.toLrcTimestamp +import pl.lambada.songsync.util.networking.Ktor.client +import pl.lambada.songsync.util.networking.Ktor.json + +/** + * LyricsPlus provider (github.com/ibratabian17/YouLyPlus backend): word-by-word (enhanced LRC) and line-synced + * lyrics behind a direct title/artist/duration query — no search/offset pagination. Several community mirrors + * serve the same API; we walk them in order and remember the last one that answered, so a dead mirror is only + * paid for once per run. + */ +class LyricsPlusAPI { + + companion object { + // Mirror list from the YouLyPlus project (see issue #4). The Cloudflare-workers and old Vercel + // deployments are intentionally last: one is capped at 100k requests/day, the other is often disabled. + private val BASE_URLS = listOf( + "https://lyricsplus.prjktla.my.id", // main server + "https://lyricsplus.binimum.org", // binimum's alternate server + "https://lyricsplus.atomix.one", // meow's mirror + "https://lyricsplus-seven.vercel.app",// jigen's mirror + "https://lyricsplus.prjktla.workers.dev", + "https://lyrics-plus-backend.vercel.app", + ) + + @Volatile + private var lastWorkingServer: String? = null + } + + private fun prioritizedServers(): List { + val last = lastWorkingServer + return if (last != null && last in BASE_URLS) listOf(last) + BASE_URLS.filter { it != last } + else BASE_URLS + } + + /** + * Fetches lyrics for [title]/[artist] (optionally narrowed by [durationSec] and [album]) and returns them + * as LRC — enhanced (word-by-word) when the service has a "Word" body, plain line-synced otherwise. + * Returns null when no mirror has a match. + */ + suspend fun getSyncedLyrics( + title: String, + artist: String, + durationSec: Int? = null, + album: String? = null, + multiPersonWordByWord: Boolean = false, + ): String? { + if (title.isBlank() || artist.isBlank()) return null + + for (baseUrl in prioritizedServers()) { + val response = runCatching { + val res = client.get("$baseUrl/v2/lyrics/get") { + parameter("title", title) + parameter("artist", artist) + if (durationSec != null && durationSec > 0) parameter("duration", durationSec) + if (!album.isNullOrBlank()) parameter("album", album) + } + val body = res.bodyAsText(Charsets.UTF_8) + if (res.status.value !in 200..299 || body.isBlank()) null + else json.decodeFromString(body) + }.getOrNull() + + if (response?.lyrics.isNullOrEmpty()) continue + lastWorkingServer = baseUrl + return convertToLrc(response!!, multiPersonWordByWord) + } + return null + } + + /** + * Converts a LyricsPlus response into the app's LRC dialect: `[mm:ss.SSS]` line stamps, and for "Word" + * bodies the same `word ` inline syllable timing that the Apple/QQ providers emit (see + * [pl.lambada.songsync.data.remote.PaxMusicHelper]), so the player renders it word-by-word out of the box. + */ + private fun convertToLrc(response: LyricsPlusResponse, multiPersonWordByWord: Boolean): String? { + val lyrics = response.lyrics?.takeIf { it.isNotEmpty() } ?: return null + val wordSync = response.type.equals("Word", ignoreCase = true) + + // Only tag voices when the song actually alternates between more than one singer. + val singers = lyrics.mapNotNull { it.element?.singer?.lowercase() }.distinct() + val tagVoices = multiPersonWordByWord && singers.size > 1 + val primarySinger = singers.firstOrNull() + + val sb = StringBuilder(lyrics.size * 64) + for (line in lyrics) { + val mainWords = line.syllabus?.filter { !it.isBackground }.orEmpty() + val bgWords = line.syllabus?.filter { it.isBackground }.orEmpty() + + val hasMain = if (wordSync && line.syllabus != null) mainWords.isNotEmpty() else line.text.isNotBlank() + if (hasMain) { + sb.append("[${line.time.toInt().toLrcTimestamp()}]") + if (tagVoices) { + sb.append(if (line.element?.singer?.lowercase() == primarySinger) "v1:" else "v2:") + } + if (wordSync && mainWords.isNotEmpty()) appendWordByWord(sb, mainWords) + else sb.append(line.text.trim()) + sb.append('\n') + } + + // Background vocals only make sense in the multi-person word-by-word format (mirrors PaxMusicHelper). + if (bgWords.isNotEmpty() && multiPersonWordByWord && wordSync) { + // Rewrite the trailing newline so the [bg:...] block attaches to its main line. + if (sb.endsWith("\n")) sb.setLength(sb.length - 1) + sb.append("\n[bg:") + appendWordByWord(sb, bgWords) + sb.append("]\n") + } + } + return sb.toString().trimEnd().ifBlank { null } + } + + /** Appends `word ` blocks, deduplicating an end stamp that equals the next word's begin. */ + private fun appendWordByWord(sb: StringBuilder, words: List) { + for (word in words) { + val text = word.text.trim() + if (text.isEmpty()) continue + val begin = "<${word.time.toInt().toLrcTimestamp()}>" + val end = "<${(word.time + word.duration).toInt().toLrcTimestamp()}>" + if (!sb.endsWith(begin)) sb.append(begin) + sb.append(text).append(' ') + sb.append(end) + } + } +} diff --git a/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/LyricsPlus.kt b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/LyricsPlus.kt new file mode 100644 index 0000000..56ac446 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/LyricsPlus.kt @@ -0,0 +1,49 @@ +package pl.lambada.songsync.domain.model.lyrics_providers.others + +import kotlinx.serialization.Serializable + +/** + * Response models for the LyricsPlus API (`/v2/lyrics/get`). The service aggregates Apple/Musixmatch-grade + * word-by-word lyrics behind a simple title/artist/duration query and is served from several community mirrors. + */ +@Serializable +data class LyricsPlusResponse( + val type: String? = null, + val metadata: LyricsPlusMetadata? = null, + val lyrics: List? = null, +) + +@Serializable +data class LyricsPlusMetadata( + val title: String? = null, + val language: String? = null, +) + +@Serializable +data class LyricsPlusLine( + /** Line start, milliseconds. */ + val time: Long = 0, + /** Line duration, milliseconds. */ + val duration: Long = 0, + val text: String = "", + /** Word-level timing (only present for type == "Word"). */ + val syllabus: List? = null, + val element: LyricsPlusLineElement? = null, +) + +@Serializable +data class LyricsPlusWord( + /** Word start, milliseconds. */ + val time: Long = 0, + /** Word duration, milliseconds. */ + val duration: Long = 0, + val text: String = "", + val isBackground: Boolean = false, +) + +@Serializable +data class LyricsPlusLineElement( + val key: String? = null, + /** Singer alias, e.g. "v1" / "v2" — used for the multi-person word-by-word format. */ + val singer: String? = null, +) diff --git a/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt b/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt index 42a399d..2e7b2d4 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/Navigator.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -12,6 +13,7 @@ import kotlinx.serialization.Serializable import pl.lambada.songsync.data.UserSettingsController import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService import pl.lambada.songsync.ui.common.animatedComposable +import pl.lambada.songsync.ui.screens.batch.BatchOptionsScreen import pl.lambada.songsync.ui.screens.batch.BatchProgressScreen import pl.lambada.songsync.ui.screens.home.HomeScreen import pl.lambada.songsync.util.batch.BatchDownloadController @@ -83,6 +85,23 @@ fun Navigator( animatedVisibilityScope = this, ) } + animatedComposable { entry -> + // The options page operates on the Home screen's selection/filter state, so it reuses the + // HomeViewModel scoped to the Home back-stack entry instead of creating its own. + val homeEntry = remember(entry) { navController.getBackStackEntry(ScreenHome) } + BatchOptionsScreen( + viewModel = viewModel(viewModelStoreOwner = homeEntry) { + HomeViewModel(userSettingsController, lyricsProviderService) + }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToProgress = { + // Replace the options page with the progress view, so Back from progress returns Home. + navController.navigate(ScreenBatchProgress) { + popUpTo() + } + }, + ) + } animatedComposable { BatchProgressScreen(onNavigateBack = { navController.popBackStack() }) } @@ -151,6 +170,9 @@ data class LocalSong( @Serializable object ScreenSettings +@Serializable +object ScreenBatchOptions + @Serializable object ScreenBatchProgress diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt new file mode 100644 index 0000000..187e07e --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchOptionsScreen.kt @@ -0,0 +1,211 @@ +package pl.lambada.songsync.ui.screens.batch + +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import pl.lambada.songsync.R +import pl.lambada.songsync.ui.components.SettingsHeadLabel +import pl.lambada.songsync.ui.components.SwitchItem +import pl.lambada.songsync.ui.screens.home.HomeViewModel +import pl.lambada.songsync.ui.screens.home.components.batchDownload.LegacyPromptDialog +import pl.lambada.songsync.util.matching.LyricState + +/** + * Full-screen batch download setup — replaces the old cramped options popup. Shows how many songs the run will + * process, all the run options as regular settings rows, and a bottom "Start" button that kicks the batch off + * and moves straight to the full-screen progress view. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BatchOptionsScreen( + viewModel: HomeViewModel, + onNavigateBack: () -> Unit, + onNavigateToProgress: () -> Unit, +) { + val context = LocalContext.current + val settings = viewModel.userSettingsController + val songs = viewModel.songsToBatchDownload + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + var showLegacyPrompt by remember { mutableStateOf(false) } + + val total = songs.size + // Songs the batch will actually process when skipExisting is on = those WITHOUT synced lyrics. UNSYNCED + // (a plain .lrc, no timestamps) is processable too. + val songsWithoutLyrics = songs.count { + val st = viewModel.lyricStateFor(it) + st == LyricState.NO_LYRICS || st == LyricState.FAILED || st == LyricState.UNSYNCED + } + val songsToProcess = if (settings.batchSkipExisting) songsWithoutLyrics else total + + fun startAndShowProgress() { + viewModel.startBatchDownload(context) + onNavigateToProgress() + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + title = { Text(stringResource(R.string.batch_download_lyrics)) }, + scrollBehavior = scrollBehavior, + ) + }, + bottomBar = { + Surface(color = MaterialTheme.colorScheme.surface) { + Button( + onClick = { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) showLegacyPrompt = true + else startAndShowProgress() + }, + enabled = songsToProcess > 0, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + .navigationBarsPadding(), + ) { + Icon(Icons.Filled.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.start)) + } + } + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + // The headline the user cares about: how many songs this run will actually touch. + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Filled.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = if (songsToProcess == 0) + stringResource(R.string.all_songs_have_lyrics) + else + stringResource(R.string.will_fetch_for_n_songs, songsToProcess), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + SettingsHeadLabel(label = stringResource(R.string.batch_options_general)) + + SwitchItem( + label = stringResource(R.string.skip_existing_lyrics), + description = stringResource(R.string.skip_existing_lyrics_desc), + selected = settings.batchSkipExisting, + ) { settings.updateBatchSkipExisting(!settings.batchSkipExisting) } + + SwitchItem( + label = stringResource(R.string.save_lrc_next_to_song), + description = stringResource(R.string.save_lrc_desc), + selected = settings.batchSaveLrc, + ) { settings.updateBatchSaveLrc(!settings.batchSaveLrc) } + + SettingsHeadLabel(label = stringResource(R.string.batch_options_advanced)) + + SwitchItem( + label = stringResource(R.string.skip_no_lyrics), + description = stringResource(R.string.skip_no_lyrics_desc), + selected = settings.batchSkipNoLyrics, + ) { settings.updateBatchSkipNoLyrics(!settings.batchSkipNoLyrics) } + + SwitchItem( + label = stringResource(R.string.embed_lyrics_in_file), + description = stringResource(R.string.embed_lyrics_desc), + selected = settings.batchEmbedLyrics, + ) { settings.updateBatchEmbedLyrics(!settings.batchEmbedLyrics) } + + SwitchItem( + label = stringResource(R.string.correct_metadata), + description = stringResource(R.string.correct_metadata_desc), + selected = settings.batchCorrectMetadata, + ) { settings.updateBatchCorrectMetadata(!settings.batchCorrectMetadata) } + + SwitchItem( + label = stringResource(R.string.auto_try_providers), + description = stringResource(R.string.auto_try_providers_desc), + selected = settings.batchAutoTryProviders, + ) { settings.updateBatchAutoTryProviders(!settings.batchAutoTryProviders) } + + SwitchItem( + label = stringResource(R.string.add_unsynced_if_no_synced), + description = stringResource(R.string.add_unsynced_if_no_synced_desc), + selected = settings.batchAddUnsyncedFallback, + ) { settings.updateBatchAddUnsyncedFallback(!settings.batchAddUnsyncedFallback) } + + Text( + text = stringResource(R.string.private_lrc_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 22.dp, vertical = 8.dp), + ) + + Spacer(Modifier.height(16.dp)) + } + } + + if (showLegacyPrompt) { + LegacyPromptDialog( + onConfirm = { startAndShowProgress() }, + onDismiss = { showLegacyPrompt = false }, + ) + } +} diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchProgressScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchProgressScreen.kt index 550e500..0dcdc6b 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchProgressScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/batch/BatchProgressScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -52,6 +53,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -127,6 +129,7 @@ fun BatchProgressScreen(onNavigateBack: () -> Unit) { var showKeepAliveDialog by remember { mutableStateOf(false) } var lyricsSheetSong by remember { mutableStateOf(null) } + var categorySheet by remember { mutableStateOf(null) } // The user is looking at the results now; the summary notification has served its purpose. LaunchedEffect(controller.status) { @@ -202,7 +205,7 @@ fun BatchProgressScreen(onNavigateBack: () -> Unit) { Spacer(Modifier.height(16.dp)) - CountersGrid(controller) + CountersGrid(controller, onCategoryClick = { categorySheet = it }) // Unsynced lyrics found for songs with no synced match. One tap adds all of them // (and keeps auto-adding new ones for the rest of the run). @@ -314,6 +317,157 @@ fun BatchProgressScreen(onNavigateBack: () -> Unit) { lyricsSheetSong?.let { song -> LyricsPreviewSheet(song = song, onDismiss = { lyricsSheetSong = null }) } + + categorySheet?.let { category -> + ResultCategorySheet( + category = category, + controller = controller, + onDismiss = { categorySheet = null }, + onSongClick = { song -> lyricsSheetSong = song }, + ) + } +} + +/** The tappable result buckets on the progress screen. Each opens a drawer listing the affected songs. */ +enum class ResultCategory(val titleRes: Int) { + SYNCED(R.string.batch_category_synced_title), + UNSYNCED(R.string.batch_category_unsynced_title), + NOT_FOUND(R.string.batch_category_not_found_title), + FAILED(R.string.batch_category_failed_title); + + fun matches(state: LyricState): Boolean = when (this) { + SYNCED -> state == LyricState.SYNCED || state == LyricState.REVIEW + UNSYNCED -> state == LyricState.UNSYNCED + NOT_FOUND -> state == LyricState.NO_LYRICS + FAILED -> state == LyricState.FAILED + } +} + +/** + * Drawer listing every song that landed in a result bucket. Opens at half the screen and can be dragged up + * to full height (or down to dismiss). Tapping a song that has lyrics opens the lyrics preview. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ResultCategorySheet( + category: ResultCategory, + controller: BatchDownloadController, + onDismiss: () -> Unit, + onSongClick: (Song) -> Unit, +) { + // skipPartiallyExpanded = false => the sheet rests at ~half screen first and expands to full when dragged. + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + val entries = controller.processed.filter { category.matches(it.info.state) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(category.titleRes), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = entries.size.toString(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + + if (entries.isEmpty()) { + Box( + Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.batch_category_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(entries.size) { index -> + val entry = entries[index] + CategorySongRow( + entry = entry, + showLyricsHint = entry.info.hasLyrics || entry.info.state == LyricState.UNSYNCED, + onClick = { onSongClick(entry.song) }, + ) + } + item { Spacer(Modifier.height(24.dp)) } + } + } + } + } +} + +@Composable +private fun CategorySongRow( + entry: BatchDownloadController.ProcessedSong, + showLyricsHint: Boolean, + onClick: () -> Unit, +) { + val song = entry.song + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(song.imgUri).apply { + placeholder(R.drawable.ic_song) + error(R.drawable.ic_song) + }.build() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = showLyricsHint, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painter, + contentDescription = stringResource(R.string.album_cover), + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = song.title ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitleParts = listOfNotNull( + song.artist, + entry.info.provider?.displayName, + ) + Text( + text = subtitleParts.joinToString(" · ").ifBlank { stringResource(R.string.unknown) }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (showLyricsHint) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = stringResource(R.string.tap_to_view_lyrics), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } } /** The live (rightmost) page: the song currently being matched, or the run summary once finished. */ @@ -470,9 +624,10 @@ private fun SongPage( } } -/** 2x2 grid of animated counters. Neutral M3 tones only, the label carries the meaning. */ +/** 2x2 grid of animated counters. Neutral M3 tones only, the label carries the meaning. Each tile opens a + * drawer listing the songs in that bucket. */ @Composable -private fun CountersGrid(controller: BatchDownloadController) { +private fun CountersGrid(controller: BatchDownloadController, onCategoryClick: (ResultCategory) -> Unit) { Column( modifier = Modifier .fillMaxWidth() @@ -485,11 +640,13 @@ private fun CountersGrid(controller: BatchDownloadController) { value = controller.syncedCount, emphasized = true, modifier = Modifier.weight(1f), + onClick = { onCategoryClick(ResultCategory.SYNCED) }, ) CounterTile( label = stringResource(R.string.batch_unsynced_label), value = controller.unsyncedCount, modifier = Modifier.weight(1f), + onClick = { onCategoryClick(ResultCategory.UNSYNCED) }, ) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -497,19 +654,28 @@ private fun CountersGrid(controller: BatchDownloadController) { label = stringResource(R.string.batch_not_found_label), value = controller.noLyricsCount, modifier = Modifier.weight(1f), + onClick = { onCategoryClick(ResultCategory.NOT_FOUND) }, ) CounterTile( label = stringResource(R.string.batch_failed_label), value = controller.failedCount, modifier = Modifier.weight(1f), + onClick = { onCategoryClick(ResultCategory.FAILED) }, ) } } } @Composable -private fun CounterTile(label: String, value: Int, modifier: Modifier = Modifier, emphasized: Boolean = false) { +private fun CounterTile( + label: String, + value: Int, + modifier: Modifier = Modifier, + emphasized: Boolean = false, + onClick: () -> Unit = {}, +) { Surface( + onClick = onClick, shape = RoundedCornerShape(20.dp), color = if (emphasized) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt index df932b6..0566955 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeScreen.kt @@ -54,7 +54,6 @@ import pl.lambada.songsync.R import pl.lambada.songsync.domain.model.Song import pl.lambada.songsync.ui.LyricsFetchScreen import pl.lambada.songsync.ui.ScreenSettings -import pl.lambada.songsync.ui.screens.home.components.BatchDownloadLyrics import pl.lambada.songsync.ui.screens.home.components.FilterAndSongCount import pl.lambada.songsync.ui.screens.home.components.FiltersDialog import pl.lambada.songsync.ui.screens.home.components.HomeAppBar @@ -81,7 +80,6 @@ fun HomeScreen( animatedVisibilityScope: AnimatedVisibilityScope, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - var isBatchDownload by remember { mutableStateOf(false) } val context = LocalContext.current // ensureSongsLoaded (not a raw reload): this effect re-fires every time Home re-enters composition (back @@ -125,7 +123,8 @@ fun HomeScreen( onSelectedClearAction = viewModel.selectedSongs::clear, onNavigateToSettingsSectionRequest = { navController.navigate(ScreenSettings) }, onProviderSelectRequest = viewModel.userSettingsController::updateSelectedProviders, - onBatchDownloadRequest = { isBatchDownload = true }, + // The batch setup is a full page now (like the progress view), not a popup. + onBatchDownloadRequest = { navController.navigate(pl.lambada.songsync.ui.ScreenBatchOptions) }, selectedProvider = viewModel.userSettingsController.selectedProvider, onSelectAllSongsRequest = viewModel::selectAllDisplayingSongs, onInvertSongSelectionRequest = viewModel::invertSongSelection, @@ -146,8 +145,6 @@ fun HomeScreen( viewModel = viewModel, selected = viewModel.selectedSongs, scaffoldPadding = paddingValues, - isBatchDownload = isBatchDownload, - onBatchDownloadState = { onBatchDownload -> isBatchDownload = onBatchDownload }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope ) @@ -181,8 +178,6 @@ fun HomeScreenLoaded( navController: NavHostController, viewModel: HomeViewModel, scaffoldPadding: PaddingValues, - isBatchDownload: Boolean, - onBatchDownloadState: (isBatchDownload: Boolean) -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, ) { @@ -190,14 +185,6 @@ fun HomeScreenLoaded( val scope = rememberCoroutineScope() val refreshState = rememberPullToRefreshState() - if (isBatchDownload) { - BatchDownloadLyrics( - viewModel = viewModel, - onDone = { onBatchDownloadState(false) }, - onNavigateToProgress = { navController.navigate(pl.lambada.songsync.ui.ScreenBatchProgress) }, - ) - } - Box(modifier = Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = viewModel.isRefreshing, @@ -376,7 +363,7 @@ fun HomeScreenLoaded( ExtendedFloatingActionButton( onClick = { if (batchRunning) navController.navigate(pl.lambada.songsync.ui.ScreenBatchProgress) - else onBatchDownloadState(true) + else navController.navigate(pl.lambada.songsync.ui.ScreenBatchOptions) }, modifier = Modifier .align(Alignment.BottomCenter) diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt index 9dea35a..a0ab35e 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/home/HomeViewModel.kt @@ -30,6 +30,7 @@ import pl.lambada.songsync.domain.model.Song import pl.lambada.songsync.domain.model.SongInfo import pl.lambada.songsync.domain.model.SortOrders import pl.lambada.songsync.domain.model.SortValues +import pl.lambada.songsync.util.EmbeddedLyrics import pl.lambada.songsync.util.batch.BatchDownloadController import pl.lambada.songsync.util.cache.SongCache import pl.lambada.songsync.util.matching.LrcPrescan @@ -238,11 +239,14 @@ class HomeViewModel( val songs = cachedSongs ?: return null val job = viewModelScope.launch(Dispatchers.IO) { try { - val results = LrcPrescan.scan(songs.mapNotNull { it.filePath }) + // Sidecar .lrc first, then lyrics embedded in the audio tags (issue #5): embedded-only songs + // used to show as "missing lyrics" because only sibling files were checked. + val results = LrcPrescan.scan(songs.mapNotNull { it.filePath }, EmbeddedLyrics::read) val states = results.mapValues { (_, r) -> when (r) { - PrescanResult.ALREADY_SYNCED, PrescanResult.RENAMED_FROM_PRIVATE -> LyricState.HAS_LYRICS - PrescanResult.ALREADY_PRESENT_UNSYNCED -> LyricState.UNSYNCED + PrescanResult.ALREADY_SYNCED, PrescanResult.RENAMED_FROM_PRIVATE, + PrescanResult.EMBEDDED_SYNCED -> LyricState.HAS_LYRICS + PrescanResult.ALREADY_PRESENT_UNSYNCED, PrescanResult.EMBEDDED_UNSYNCED -> LyricState.UNSYNCED PrescanResult.NONE -> LyricState.NO_LYRICS } } diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/BatchDownloadLyrics.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/BatchDownloadLyrics.kt deleted file mode 100644 index 0f638cb..0000000 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/BatchDownloadLyrics.kt +++ /dev/null @@ -1,78 +0,0 @@ -package pl.lambada.songsync.ui.screens.home.components - -import android.os.Build -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import pl.lambada.songsync.ui.screens.home.HomeViewModel -import pl.lambada.songsync.ui.screens.home.components.batchDownload.BatchDownloadWarningDialog -import pl.lambada.songsync.ui.screens.home.components.batchDownload.LegacyPromptDialog -import pl.lambada.songsync.util.matching.LyricState - -/** - * Entry point of a batch run: the options dialog (plus the legacy Android prompt). Confirming starts the - * batch in the app-scoped [pl.lambada.songsync.util.batch.BatchDownloadController] and opens the full-screen - * progress view, which replaced the old cramped progress dialog. - */ -@Composable -fun BatchDownloadLyrics( - viewModel: HomeViewModel, - onDone: () -> Unit, - onNavigateToProgress: () -> Unit, -) { - val songs = viewModel.songsToBatchDownload - var showLegacyPrompt by remember { mutableStateOf(false) } - val context = LocalContext.current - - // Toggles live in persisted user settings so choices like "Embed lyrics" and "Add unsynced fallback" - // survive between runs and app restarts. - val settings = viewModel.userSettingsController - - val total = songs.size - // Songs the batch will actually process when skipExisting is on = those WITHOUT synced lyrics. UNSYNCED - // (a plain .lrc, no timestamps) is processable too. - val songsWithoutLyrics = songs.count { - val st = viewModel.lyricStateFor(it) - st == LyricState.NO_LYRICS || st == LyricState.FAILED || st == LyricState.UNSYNCED - } - val songsToProcess = if (settings.batchSkipExisting) songsWithoutLyrics else total - - fun startAndShowProgress() { - viewModel.startBatchDownload(context) - onDone() - onNavigateToProgress() - } - - if (showLegacyPrompt) { - LegacyPromptDialog( - onConfirm = { startAndShowProgress() }, - onDismiss = { onDone() } - ) - } else { - BatchDownloadWarningDialog( - songsToProcess = songsToProcess, - onConfirm = { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) showLegacyPrompt = true - else startAndShowProgress() - }, - onDismiss = { onDone() }, - saveLrc = settings.batchSaveLrc, - onSaveLrcChangeRequest = settings::updateBatchSaveLrc, - embedLyrics = settings.batchEmbedLyrics, - onEmbedLyricsChangeRequest = settings::updateBatchEmbedLyrics, - correctMetadata = settings.batchCorrectMetadata, - onCorrectMetadataChangeRequest = settings::updateBatchCorrectMetadata, - skipExisting = settings.batchSkipExisting, - onSkipExistingChangeRequest = settings::updateBatchSkipExisting, - skipNoLyrics = settings.batchSkipNoLyrics, - onSkipNoLyricsChangeRequest = settings::updateBatchSkipNoLyrics, - autoTryProviders = settings.batchAutoTryProviders, - onAutoTryProvidersChangeRequest = settings::updateBatchAutoTryProviders, - addUnsyncedFallback = settings.batchAddUnsyncedFallback, - onAddUnsyncedFallbackChangeRequest = settings::updateBatchAddUnsyncedFallback, - ) - } -} diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/BatchDownloadWarningDialog.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/BatchDownloadWarningDialog.kt deleted file mode 100644 index e299f99..0000000 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/home/components/batchDownload/BatchDownloadWarningDialog.kt +++ /dev/null @@ -1,169 +0,0 @@ -package pl.lambada.songsync.ui.screens.home.components.batchDownload - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import pl.lambada.songsync.R -import pl.lambada.songsync.ui.components.SwitchItem - -@Composable -fun BatchDownloadWarningDialog( - songsToProcess: Int, - onConfirm: () -> Unit, - onDismiss: () -> Unit, - saveLrc: Boolean, - onSaveLrcChangeRequest: (Boolean) -> Unit, - embedLyrics: Boolean, - onEmbedLyricsChangeRequest: (Boolean) -> Unit, - correctMetadata: Boolean, - onCorrectMetadataChangeRequest: (Boolean) -> Unit, - skipExisting: Boolean, - onSkipExistingChangeRequest: (Boolean) -> Unit, - skipNoLyrics: Boolean, - onSkipNoLyricsChangeRequest: (Boolean) -> Unit, - autoTryProviders: Boolean, - onAutoTryProvidersChangeRequest: (Boolean) -> Unit, - addUnsyncedFallback: Boolean, - onAddUnsyncedFallbackChangeRequest: (Boolean) -> Unit, -) { - val rowPadding = PaddingValues(horizontal = 4.dp, vertical = 12.dp) - var advancedExpanded by remember { mutableStateOf(false) } - AlertDialog( - title = { Text(text = stringResource(id = R.string.batch_download_lyrics)) }, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text( - text = if (songsToProcess == 0) - stringResource(R.string.all_songs_have_lyrics) - else - stringResource(R.string.will_fetch_for_n_songs, songsToProcess) - ) - Spacer(modifier = Modifier.height(8.dp)) - - // The two choices that matter to almost everyone stay up top; everything else is tucked - // away so a first-time user can just tap "Yes". - SwitchItem( - label = stringResource(R.string.skip_existing_lyrics), - description = stringResource(R.string.skip_existing_lyrics_desc), - selected = skipExisting, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onSkipExistingChangeRequest(!skipExisting) } - - SwitchItem( - label = stringResource(R.string.save_lrc_next_to_song), - description = stringResource(R.string.save_lrc_desc), - selected = saveLrc, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onSaveLrcChangeRequest(!saveLrc) } - - // More options (collapsed by default) - Row( - modifier = Modifier - .clickable { advancedExpanded = !advancedExpanded } - .padding(vertical = 10.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.more_options), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) - Icon( - if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - AnimatedVisibility(visible = advancedExpanded) { - Column { - SwitchItem( - label = stringResource(R.string.skip_no_lyrics), - description = stringResource(R.string.skip_no_lyrics_desc), - selected = skipNoLyrics, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onSkipNoLyricsChangeRequest(!skipNoLyrics) } - - SwitchItem( - label = stringResource(R.string.embed_lyrics_in_file), - description = stringResource(R.string.embed_lyrics_desc), - selected = embedLyrics, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onEmbedLyricsChangeRequest(!embedLyrics) } - - SwitchItem( - label = stringResource(R.string.correct_metadata), - description = stringResource(R.string.correct_metadata_desc), - selected = correctMetadata, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onCorrectMetadataChangeRequest(!correctMetadata) } - - SwitchItem( - label = stringResource(R.string.auto_try_providers), - description = stringResource(R.string.auto_try_providers_desc), - selected = autoTryProviders, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onAutoTryProvidersChangeRequest(!autoTryProviders) } - - SwitchItem( - label = stringResource(R.string.add_unsynced_if_no_synced), - description = stringResource(R.string.add_unsynced_if_no_synced_desc), - selected = addUnsyncedFallback, - modifier = Modifier, - innerPaddingValues = rowPadding, - ) { onAddUnsyncedFallbackChangeRequest(!addUnsyncedFallback) } - - Text( - text = stringResource(R.string.private_lrc_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp) - ) - } - } - } - }, - onDismissRequest = onDismiss, - confirmButton = { - Button(onClick = onConfirm, enabled = songsToProcess > 0) { - Text(text = stringResource(R.string.yes)) - } - }, - dismissButton = { - OutlinedButton(onClick = onDismiss) { - Text(text = stringResource(R.string.no)) - } - } - ) -} diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt index 8d7874e..e830096 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/lyricsFetch/LyricsFetchViewModel.kt @@ -120,9 +120,11 @@ class LyricsFetchViewModel( val candidates = FilenameParser.candidates(querySongName, queryArtistName, source?.filePath) .ifEmpty { listOf(QueryCandidate(querySongName, queryArtistName ?: "", MatchStrategy.TAGS)) } - // Selected provider first, then the rest as automatic fallback. + // Selected provider first, then the user's configured fallback chain (Settings > Provider order). + // Providers the user disabled are not queried automatically (they stay available via the + // per-provider dropdown/retry). val order = (listOf(userSettingsController.selectedProvider) + - Providers.entries.filter { it != userSettingsController.selectedProvider }).distinct() + userSettingsController.enabledProviderOrder).distinct() // Track which providers the search actually reached. The ladder can stop early (auto-accept), so // the rest are genuinely UNTRIED — not failures — and must not be painted with a red X. diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/settings/SettingsScreen.kt index b217fc1..35362c8 100644 --- a/app/src/main/java/pl/lambada/songsync/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/settings/SettingsScreen.kt @@ -28,6 +28,7 @@ import pl.lambada.songsync.ui.screens.settings.components.CreditsSection import pl.lambada.songsync.ui.screens.settings.components.ExternalLinkSection import pl.lambada.songsync.ui.screens.settings.components.MarqueeSwitch import pl.lambada.songsync.ui.screens.settings.components.MultiPersonSwitch +import pl.lambada.songsync.ui.screens.settings.components.ProviderOrderSection import pl.lambada.songsync.ui.screens.settings.components.PureBlackThemeSwitch import pl.lambada.songsync.ui.screens.settings.components.RomanizationSwitch import pl.lambada.songsync.ui.screens.settings.components.SdCardPathSetting @@ -105,6 +106,27 @@ fun SettingsScreen( } item { SpotifySecretsStatus() } + item { SettingsHeadLabel(label = stringResource(id = R.string.provider_order)) } + item { + ProviderOrderSection( + order = userSettingsController.providerOrder, + disabled = userSettingsController.disabledProviders, + onMove = { provider, delta -> + val current = userSettingsController.providerOrder.toMutableList() + val from = current.indexOf(provider) + val to = from + delta + if (from != -1 && to in current.indices) { + current.removeAt(from) + current.add(to, provider) + userSettingsController.updateProviderOrder(current) + } + }, + onToggle = { provider, enabled -> + userSettingsController.updateProviderEnabled(provider, enabled) + }, + ) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { item { SettingsHeadLabel(label = stringResource(R.string.sd_card)) } item { diff --git a/app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ProviderOrderSection.kt b/app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ProviderOrderSection.kt new file mode 100644 index 0000000..ad27839 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/ui/screens/settings/components/ProviderOrderSection.kt @@ -0,0 +1,86 @@ +package pl.lambada.songsync.ui.screens.settings.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import pl.lambada.songsync.R +import pl.lambada.songsync.util.Providers + +/** + * Settings section for the provider fallback chain (issue #6): every provider is listed in the order it will + * be tried during a search/batch; the arrows rearrange the chain and the checkbox removes a provider from it. + */ +@Composable +fun ProviderOrderSection( + order: List, + disabled: Set, + onMove: (Providers, Int) -> Unit, + onToggle: (Providers, Boolean) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.provider_order_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 22.dp, vertical = 4.dp), + ) + order.forEachIndexed { index, provider -> + val enabled = provider !in disabled + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = enabled, + onCheckedChange = { onToggle(provider, it) }, + ) + Column(Modifier.weight(1f)) { + Text( + text = "${index + 1}. ${provider.displayName}", + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + if (provider.hasWordByWord) { + Text( + text = stringResource(R.string.provider_word_by_word), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton( + onClick = { onMove(provider, -1) }, + enabled = index > 0, + modifier = Modifier.size(36.dp), + ) { + Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.move_up)) + } + IconButton( + onClick = { onMove(provider, +1) }, + enabled = index < order.lastIndex, + modifier = Modifier.size(36.dp), + ) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.move_down)) + } + } + } + } +} diff --git a/app/src/main/java/pl/lambada/songsync/util/EmbeddedLyrics.kt b/app/src/main/java/pl/lambada/songsync/util/EmbeddedLyrics.kt new file mode 100644 index 0000000..fea12bd --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/util/EmbeddedLyrics.kt @@ -0,0 +1,59 @@ +package pl.lambada.songsync.util + +import android.os.ParcelFileDescriptor +import com.kyant.taglib.TagLib +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Reads lyrics embedded in an audio file's tags (USLT/©lyr/LYRICS, i.e. what "Embed lyrics" writes) via TagLib. + * + * This is what lets the home page recognize songs whose lyrics live INSIDE the file instead of a sidecar .lrc + * (issue #5: every embedded-only track showed as "missing lyrics"). Results are memoised per path keyed on the + * file's (lastModified, length) so the full-library re-scan on every Home resume doesn't re-open thousands of + * files with TagLib each time. + */ +object EmbeddedLyrics { + + /** Tag keys that carry lyrics across formats once TagLib normalizes them into the property map. */ + private val LYRIC_KEYS = setOf("LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS", "LYRICS:LANG") + + private data class CacheEntry(val lastModified: Long, val length: Long, val lyrics: String?) + + private val cache = ConcurrentHashMap() + + /** Returns the embedded lyrics body for [filePath], or null when the file has none. Blocking — call on IO. */ + fun read(filePath: String): String? { + val file = File(filePath) + if (!file.exists()) return null + + val lastModified = file.lastModified() + val length = file.length() + cache[filePath]?.let { if (it.lastModified == lastModified && it.length == length) return it.lyrics } + + val lyrics = runCatching { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + val metadata = TagLib.getMetadata(pfd.dup().detachFd(), false) + metadata?.propertyMap?.entries + ?.firstOrNull { (key, values) -> + normalizeKey(key) in LYRIC_KEYS && values.any { it.isNotBlank() } + } + ?.value?.firstOrNull { it.isNotBlank() } + } + }.getOrNull() + + cache[filePath] = CacheEntry(lastModified, length, lyrics) + return lyrics + } + + /** Drops the memoised entry, e.g. right after this app embedded new lyrics into the file. */ + fun invalidate(filePath: String) { + cache.remove(filePath) + } + + /** "unsynced lyrics" / "Lyrics" / "LYRICS:eng" all collapse onto the canonical keys. */ + private fun normalizeKey(key: String): String { + val upper = key.uppercase().replace(" ", "") + return if (upper.startsWith("LYRICS:")) "LYRICS:LANG" else upper + } +} diff --git a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt index ea51a4e..b1d5963 100644 --- a/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt +++ b/app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt @@ -194,6 +194,8 @@ fun embedLyricsInFile( propertyMap = metadata.propertyMap.apply { put("LYRICS", arrayOf(lyrics.toCrlf())) } ) + // The embedded-lyrics scan memoises per file; the tags just changed, so force a fresh read next time. + EmbeddedLyrics.invalidate(filePath) true } catch (securityException: SecurityException) { handleSecurityException(securityException, securityExceptionHandler) @@ -268,11 +270,28 @@ fun handleSecurityException( enum class Providers(val displayName: String, val hasWordByWord: Boolean) { APPLE("Apple Music", true), LRCLIB("LRCLib", false), + LYRICSPLUS("LyricsPlus", true), + BETTERLYRICS("BetterLyrics", true), SPOTIFY("Spotify", false), QQMUSIC("QQ Music", true), NETEASE("Netease", false) { val inf = 0 }, } +/** + * Default provider fallback chain (issue #6): the order providers are tried in until one has a match, unless + * the user rearranges it in Settings. LRCLib first (fast, no auth), then the word-by-word direct providers, + * then the searchable catalogues. + */ +val defaultProviderFallbackOrder: List = listOf( + Providers.LRCLIB, + Providers.LYRICSPLUS, + Providers.BETTERLYRICS, + Providers.NETEASE, + Providers.APPLE, + Providers.SPOTIFY, + Providers.QQMUSIC, +) + /** * A plain (unsynced) lyrics hit that was found during the batch but NOT saved because the unsynced fallback * toggle was off. Kept in memory so the user can add them all with one tap from the batch screen. @@ -311,12 +330,11 @@ suspend fun downloadLyrics( var skippedCount = 0 // already had synced lyrics and skipExisting was on — NOT a download var consecutiveFailures = 0 - // Auto-try ON: fall through every provider (same reach as the single-song search), so a song that only has - // synced lyrics on, say, Apple or Netease is still found in batch. OFF: LRCLib only (fastest). + // Auto-try ON: walk the user's provider chain (Settings > Provider order) one by one until a match is found + // (issue #6), so a song that only has synced lyrics on, say, Apple or LyricsPlus is still found in batch. + // OFF: only the first provider of the chain (fastest). val matcher = SmartLyricsMatcher(providerService = LyricsProviderService()) - val defaultProviders = listOf(Providers.LRCLIB, Providers.NETEASE, Providers.APPLE, Providers.SPOTIFY, Providers.QQMUSIC) - var lastSuccessfulProvider: Providers? = null - val providerSuccessCount = linkedMapOf().apply { defaultProviders.forEach { put(it, 0) } } + val configuredProviders = settings.enabledProviderOrder songs.forEach { song -> // Stop promptly when the user presses Stop: the batch coroutine is cancelled and this throws @@ -325,10 +343,13 @@ suspend fun downloadLyrics( onSongStarted(song) - // skipExisting only skips songs that already have *synced* lyrics. A plain (unsynced) .lrc is treated - // as missing -- we fetch a synced version and overwrite it -- so "Has Lyrics" never lies. + // skipExisting only skips songs that already have *synced* lyrics — from a sidecar .lrc OR embedded in + // the file's tags (issue #5). A plain (unsynced) body is treated as missing -- we fetch a synced version + // and overwrite it -- so "Has Lyrics" never lies. val existingLrc = song.filePath.toLrcFile() - val hasSynced = existingLrc?.exists() == true && LrcPrescan.isSyncedLrc(existingLrc) + val hasSynced = (existingLrc?.exists() == true && LrcPrescan.isSyncedLrc(existingLrc)) || + (song.filePath != null && EmbeddedLyrics.read(song.filePath) + ?.let { LrcPrescan.isSyncedContent(it) } == true) if (skipExisting && hasSynced) { onSongResult(song, SongMatchInfo(LyricState.HAS_LYRICS)) // Already-synced songs are skipped, not downloaded — count them separately so the summary doesn't @@ -338,20 +359,9 @@ suspend fun downloadLyrics( return@forEach } - // Provider order adapts during the batch: try the last successful provider first, then the providers that - // have found the most songs so far, then the remaining defaults. This keeps the batch on the currently - // "hot" provider instead of restarting from LRCLib/Netease every single time. - val providerOrder = if (!autoTryProviders) { - listOf(Providers.LRCLIB) - } else { - buildList { - lastSuccessfulProvider?.let { add(it) } - defaultProviders - .sortedWith(compareByDescending { providerSuccessCount[it] ?: 0 } - .thenBy { defaultProviders.indexOf(it) }) - .forEach { if (it !in this) add(it) } - } - } + // The chain is tried strictly in the user's configured order (issue #6) — no adaptive reshuffling, so + // the behaviour always matches what Settings shows. + val providerOrder = if (!autoTryProviders) listOf(configuredProviders.first()) else configuredProviders val songConfig = MatchConfig(providerOrder = providerOrder) // Always overwrite once we've decided to process: replaces a stale/plain .lrc with the synced result. @@ -370,11 +380,6 @@ suspend fun downloadLyrics( LyricState.FETCHING -> { /* not a terminal state */ } } - if (info.provider != null && info.state in listOf(LyricState.SYNCED, LyricState.REVIEW, LyricState.UNSYNCED, LyricState.HAS_LYRICS)) { - lastSuccessfulProvider = info.provider - providerSuccessCount[info.provider] = (providerSuccessCount[info.provider] ?: 0) + 1 - } - onSongResult(song, info) onProgressUpdate(successCount, noLyricsCount, failedCount, skippedCount) diff --git a/app/src/main/java/pl/lambada/songsync/util/TtmlLyricsParser.kt b/app/src/main/java/pl/lambada/songsync/util/TtmlLyricsParser.kt new file mode 100644 index 0000000..022e371 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/util/TtmlLyricsParser.kt @@ -0,0 +1,230 @@ +package pl.lambada.songsync.util + +import org.w3c.dom.Element +import org.w3c.dom.Node +import pl.lambada.songsync.util.ext.toLrcTimestamp +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Minimal TTML (Timed Text Markup Language) lyrics parser used by the BetterLyrics provider. Apple-style TTML + * carries one `

` per line, with optional `` children for word-level timing, + * `ttm:agent` for the singer and `ttm:role="x-bg"` spans for background vocals. + * + * Pure Kotlin + javax.xml (present on both Android and the JVM), so it's unit-testable without an emulator. + */ +object TtmlLyricsParser { + + data class Word(val text: String, val beginMs: Long, val endMs: Long) + + /** A raw `` plus whether whitespace followed it — syllable spans with no gap merge into one word. */ + private data class SpanInfo(val text: String, val beginMs: Long, val endMs: Long, val trailingSpace: Boolean) + + data class Line( + val text: String, + val beginMs: Long, + val words: List, + val agent: String?, + val backgroundWords: List, + ) + + /** Namespace-tolerant attribute lookup: matches `agent`, `ttm:agent` or any `*:agent`. */ + private fun Element.attributeByLocalName(localName: String): String { + getAttribute(localName).takeIf { it.isNotEmpty() }?.let { return it } + getAttribute("ttm:$localName").takeIf { it.isNotEmpty() }?.let { return it } + val attrs = attributes + for (i in 0 until attrs.length) { + val attr = attrs.item(i) + val name = attr.nodeName ?: continue + if (name == localName || name.endsWith(":$localName")) return attr.nodeValue.orEmpty() + } + return "" + } + + fun parse(ttml: String): List = runCatching { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + // Defensive: never resolve external entities from a lyrics payload. + runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) } + } + val doc = factory.newDocumentBuilder().parse(ttml.byteInputStream()) + val out = mutableListOf() + + val pElements = doc.getElementsByTagName("p") + for (i in 0 until pElements.length) { + val p = pElements.item(i) as? Element ?: continue + val begin = p.getAttribute("begin") + if (begin.isNullOrEmpty()) continue + val beginMs = parseTimeMs(begin) ?: continue + + val spans = mutableListOf() + val bgWords = mutableListOf() + val children = p.childNodes + for (j in 0 until children.length) { + val node = children.item(j) + if (node.nodeType != Node.ELEMENT_NODE) continue + val span = node as? Element ?: continue + if (!span.tagName.endsWith("span", ignoreCase = true)) continue + + when (span.attributeByLocalName("role")) { + "x-bg" -> collectWordSpans(span, bgWords) + "x-translation", "x-roman" -> Unit // decoration, skip + else -> addWordSpan(span, spans) + } + } + val words = mergeSyllables(spans) + + val text = if (words.isNotEmpty()) words.joinToString(" ") { it.text } + else directText(p).trim() + if (text.isEmpty() && bgWords.isEmpty()) continue + + out.add( + Line( + text = text, + beginMs = beginMs, + words = words, + agent = p.attributeByLocalName("agent").ifEmpty { null }, + backgroundWords = bgWords, + ) + ) + } + out + }.getOrDefault(emptyList()) + + private fun addWordSpan(span: Element, into: MutableList) { + val begin = parseTimeMs(span.getAttribute("begin")) ?: return + val end = parseTimeMs(span.getAttribute("end")) ?: return + val text = span.textContent?.trim().orEmpty() + if (text.isEmpty()) return + // Whitespace between this span and the next marks a word boundary; its absence means the spans are + // syllables of ONE word (Apple-style TTML) and must be glued back together. + val next = span.nextSibling + val trailingSpace = next?.nodeType == Node.TEXT_NODE && next.textContent?.any { it.isWhitespace() } == true + into.add(SpanInfo(text, begin, end, trailingSpace)) + } + + /** Glues syllable spans (no whitespace between them) into whole words spanning first-begin..last-end. */ + private fun mergeSyllables(spans: List): List { + val words = mutableListOf() + var text = StringBuilder() + var begin = 0L + var end = 0L + spans.forEach { span -> + if (text.isEmpty()) { + begin = span.beginMs + } + text.append(span.text) + end = span.endMs + if (span.trailingSpace) { + words.add(Word(text.toString(), begin, end)) + text = StringBuilder() + } + } + if (text.isNotEmpty()) words.add(Word(text.toString(), begin, end)) + return words + } + + /** Collects the timed inner spans of a background-vocal wrapper span, merged into words. */ + private fun collectWordSpans(wrapper: Element, into: MutableList) { + val spans = mutableListOf() + val children = wrapper.childNodes + for (i in 0 until children.length) { + val node = children.item(i) + if (node.nodeType != Node.ELEMENT_NODE) continue + val span = node as? Element ?: continue + if (!span.tagName.endsWith("span", ignoreCase = true)) continue + val role = span.attributeByLocalName("role") + if (role == "x-translation" || role == "x-roman") continue + addWordSpan(span, spans) + } + // A background span may itself be a single timed word with no children. + if (spans.isEmpty()) addWordSpan(wrapper, spans) + into.addAll(mergeSyllables(spans)) + } + + /** Text directly inside the element (word spans included, bg/translation spans excluded). */ + private fun directText(element: Element): String { + val sb = StringBuilder() + val children = element.childNodes + for (i in 0 until children.length) { + val node = children.item(i) + when (node.nodeType) { + Node.TEXT_NODE -> sb.append(node.textContent) + Node.ELEMENT_NODE -> { + val el = node as? Element ?: continue + val role = el.attributeByLocalName("role") + if (role != "x-bg" && role != "x-translation" && role != "x-roman") { + sb.append(el.textContent.orEmpty()) + } + } + } + } + return sb.toString() + } + + /** + * Parses a TTML time expression to milliseconds: `hh:mm:ss.fff`, `mm:ss.fff`, plain seconds (`12.34`) + * and the `12.34s` / `1234ms` offset forms. Returns null for anything unrecognized. + */ + fun parseTimeMs(raw: String?): Long? { + val s = raw?.trim().orEmpty() + if (s.isEmpty()) return null + return runCatching { + when { + s.contains(':') -> { + val parts = s.split(':') + val seconds = when (parts.size) { + 2 -> parts[0].toDouble() * 60 + parts[1].toDouble() + 3 -> parts[0].toDouble() * 3600 + parts[1].toDouble() * 60 + parts[2].toDouble() + else -> return null + } + (seconds * 1000).toLong() + } + s.endsWith("ms") -> s.dropLast(2).toDouble().toLong() + s.endsWith("s") -> (s.dropLast(1).toDouble() * 1000).toLong() + else -> (s.toDouble() * 1000).toLong() + } + }.getOrNull() + } + + /** + * Formats parsed lines as the app's LRC dialect (same shape as the Apple/QQ output in + * [pl.lambada.songsync.data.remote.PaxMusicHelper]): `[mm:ss.SSS]` line stamps with `word ` + * inline timing when word-level data exists, `v1:`/`v2:` voice prefixes for multi-singer songs, and + * `[bg:...]` background blocks — both only when [multiPersonWordByWord] is on. + */ + fun toLrc(lines: List, multiPersonWordByWord: Boolean = false): String? { + if (lines.isEmpty()) return null + val agents = lines.mapNotNull { it.agent }.distinct() + val tagVoices = multiPersonWordByWord && agents.size > 1 + val primaryAgent = agents.firstOrNull() + + val sb = StringBuilder(lines.size * 64) + for (line in lines) { + if (line.text.isNotBlank() || line.words.isNotEmpty()) { + sb.append("[${line.beginMs.toInt().toLrcTimestamp()}]") + if (tagVoices) sb.append(if (line.agent == null || line.agent == primaryAgent) "v1:" else "v2:") + if (line.words.isNotEmpty()) appendWordByWord(sb, line.words) + else sb.append(line.text.trim()) + sb.append('\n') + } + + if (line.backgroundWords.isNotEmpty() && multiPersonWordByWord) { + if (sb.endsWith("\n")) sb.setLength(sb.length - 1) + sb.append("\n[bg:") + appendWordByWord(sb, line.backgroundWords) + sb.append("]\n") + } + } + return sb.toString().trimEnd().ifBlank { null } + } + + private fun appendWordByWord(sb: StringBuilder, words: List) { + for (word in words) { + val begin = "<${word.beginMs.toInt().toLrcTimestamp()}>" + val end = "<${word.endMs.toInt().toLrcTimestamp()}>" + if (!sb.endsWith(begin)) sb.append(begin) + sb.append(word.text).append(' ') + sb.append(end) + } + } +} diff --git a/app/src/main/java/pl/lambada/songsync/util/matching/LrcPrescan.kt b/app/src/main/java/pl/lambada/songsync/util/matching/LrcPrescan.kt index 3e97852..cca77ee 100644 --- a/app/src/main/java/pl/lambada/songsync/util/matching/LrcPrescan.kt +++ b/app/src/main/java/pl/lambada/songsync/util/matching/LrcPrescan.kt @@ -20,6 +20,12 @@ enum class PrescanResult { */ ALREADY_PRESENT_UNSYNCED, + /** No sidecar `.lrc`, but the audio file itself carries SYNCED lyrics in its tags (USLT/LYRICS). */ + EMBEDDED_SYNCED, + + /** No sidecar `.lrc`, but the audio file carries plain (unsynced) lyrics in its tags. */ + EMBEDDED_UNSYNCED, + /** No usable sibling lyrics file was found. The song needs online fetching. */ NONE } @@ -57,6 +63,10 @@ object LrcPrescan { }.getOrDefault(false) } + /** Same synced-vs-plain call as [isSyncedLrc], but for an in-memory body (e.g. lyrics embedded in tags). */ + fun isSyncedContent(lyrics: String): Boolean = + lyrics.lineSequence().take(200).any { timestampRegex.containsMatchIn(it) } + /** * Ensures `.lrc` exists next to [audioPath] if any usable variant is present. * @return what was done (see [PrescanResult]). @@ -69,6 +79,8 @@ object LrcPrescan { fun resolveForAudio( audioPath: String, dirCache: MutableMap>? = null, + /** Optional tag reader (injected on Android, null in JVM tests): lyrics embedded in the audio file. */ + embeddedLyricsReader: ((String) -> String?)? = null, ): PrescanResult { val audio = File(audioPath) val dir = audio.parentFile ?: return PrescanResult.NONE @@ -77,10 +89,25 @@ object LrcPrescan { if (isSyncedLrc(target)) return PrescanResult.ALREADY_SYNCED + /** + * No usable sidecar found — fall back to lyrics embedded in the audio's own tags (issue #5: songs whose + * lyrics were embedded into the file used to show as "missing"). A synced embedded body outranks a plain + * sidecar, since the tabs and batch care about *synced* lyrics. + */ + fun embeddedFallback(): PrescanResult { + val embedded = embeddedLyricsReader?.invoke(audioPath) + return when { + embedded != null && isSyncedContent(embedded) -> PrescanResult.EMBEDDED_SYNCED + target.exists() -> PrescanResult.ALREADY_PRESENT_UNSYNCED + !embedded.isNullOrBlank() -> PrescanResult.EMBEDDED_UNSYNCED + else -> PrescanResult.NONE + } + } + val candidates = collectPrivateVariants(dir, stem, dirCache) // Prefer a candidate that is actually synced; fall back to the first that exists. val best = candidates.firstOrNull { isSyncedLrc(it) } ?: candidates.firstOrNull() - ?: return if (target.exists()) PrescanResult.ALREADY_PRESENT_UNSYNCED else PrescanResult.NONE + ?: return embeddedFallback() // Replace a stale/empty target if present, then move the private file into place. if (target.exists()) target.delete() @@ -88,7 +115,7 @@ object LrcPrescan { best.copyTo(target, overwrite = true); best.delete(); true }.getOrDefault(false) - if (!moved) return PrescanResult.NONE + if (!moved) return embeddedFallback() // Re-check the renamed target: a `_private` file can be plain (unsynced). Only report it as a synced // rescue when it actually carries timestamps; otherwise it's an unsynced lyrics file and must NOT be // coloured green as if it had synced lyrics. @@ -124,8 +151,12 @@ object LrcPrescan { * Runs [resolveForAudio] for every path. Returns a map of audioPath -> result so callers can * count how many songs gained lyrics without any network request. */ - fun scan(audioPaths: List): Map { + @JvmOverloads + fun scan( + audioPaths: List, + embeddedLyricsReader: ((String) -> String?)? = null, + ): Map { val dirCache = HashMap>() - return audioPaths.associateWith { resolveForAudio(it, dirCache) } + return audioPaths.associateWith { resolveForAudio(it, dirCache, embeddedLyricsReader) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f524261..e660934 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,7 +17,7 @@ All songs already have lyrics. Turn off \"Skip existing\" to re-fetch. Advanced Try every provider - Slower but finds more. Turn off for a quick LRCLib-only pass + Slower but finds more. Turn off to only query the first provider in your provider order Snaptube downloads: matching \"_private\" .lrc files are renamed automatically so players can see them. Please verify Searching… @@ -281,4 +281,25 @@ May be outdated · refreshed %1$s ago. Spotify search may fail. Refresh Refreshing… + + + Provider order + Providers are tried one by one, from top to bottom, until lyrics are found. Uncheck a provider to skip it. + Word-by-word lyrics + Move up + Move down + + + Batch download + Options + Advanced + Start + + + Synced lyrics + Unsynced lyrics + No lyrics found + Failed + Skipped + Nothing here yet diff --git a/app/src/test/java/pl/lambada/songsync/matching/EmbeddedLyricsPrescanTest.kt b/app/src/test/java/pl/lambada/songsync/matching/EmbeddedLyricsPrescanTest.kt new file mode 100644 index 0000000..c19ace6 --- /dev/null +++ b/app/src/test/java/pl/lambada/songsync/matching/EmbeddedLyricsPrescanTest.kt @@ -0,0 +1,80 @@ +package pl.lambada.songsync.matching + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import pl.lambada.songsync.util.matching.LrcPrescan +import pl.lambada.songsync.util.matching.PrescanResult +import java.io.File + +/** + * Tests for the embedded-lyrics fallback in [LrcPrescan] (issue #5: songs whose lyrics live in the audio tags + * instead of a sidecar .lrc showed as "missing lyrics" on the home page). The tag reader is injected as a + * lambda so the tests stay JVM-only (no TagLib native code). + */ +class EmbeddedLyricsPrescanTest { + @get:Rule + val tmp = TemporaryFolder() + + private val syncedBody = "[ti:Song]\n[00:00.57] line one\n[00:02.66] line two\n" + private val plainBody = "just some words\nwithout any timestamps\n" + + private fun audio(name: String): File = tmp.newFile("$name.mp3").apply { writeText("fake") } + + @Test + fun `embedded synced lyrics are detected when no sidecar exists`() { + val song = audio("embedded-synced") + val result = LrcPrescan.resolveForAudio(song.path, null) { syncedBody } + assertEquals(PrescanResult.EMBEDDED_SYNCED, result) + } + + @Test + fun `embedded plain lyrics are detected as unsynced`() { + val song = audio("embedded-plain") + val result = LrcPrescan.resolveForAudio(song.path, null) { plainBody } + assertEquals(PrescanResult.EMBEDDED_UNSYNCED, result) + } + + @Test + fun `no sidecar and no embedded lyrics is NONE`() { + val song = audio("nothing") + val result = LrcPrescan.resolveForAudio(song.path, null) { null } + assertEquals(PrescanResult.NONE, result) + } + + @Test + fun `sidecar synced lrc wins without consulting the tag reader`() { + val song = audio("sidecar") + File(tmp.root, "sidecar.lrc").writeText(syncedBody) + val result = LrcPrescan.resolveForAudio(song.path, null) { error("must not be called") } + assertEquals(PrescanResult.ALREADY_SYNCED, result) + } + + @Test + fun `embedded synced outranks a plain sidecar`() { + val song = audio("plain-sidecar") + File(tmp.root, "plain-sidecar.lrc").writeText(plainBody) + val result = LrcPrescan.resolveForAudio(song.path, null) { syncedBody } + assertEquals(PrescanResult.EMBEDDED_SYNCED, result) + } + + @Test + fun `plain sidecar still reported when embedded is also plain`() { + val song = audio("both-plain") + File(tmp.root, "both-plain.lrc").writeText(plainBody) + val result = LrcPrescan.resolveForAudio(song.path, null) { plainBody } + assertEquals(PrescanResult.ALREADY_PRESENT_UNSYNCED, result) + } + + @Test + fun `scan passes the reader through`() { + val withEmbedded = audio("scan-embedded") + val without = audio("scan-none") + val results = LrcPrescan.scan(listOf(withEmbedded.path, without.path)) { path -> + if (path == withEmbedded.path) syncedBody else null + } + assertEquals(PrescanResult.EMBEDDED_SYNCED, results[withEmbedded.path]) + assertEquals(PrescanResult.NONE, results[without.path]) + } +} diff --git a/app/src/test/java/pl/lambada/songsync/util/ProviderOrderParsingTest.kt b/app/src/test/java/pl/lambada/songsync/util/ProviderOrderParsingTest.kt new file mode 100644 index 0000000..9575b45 --- /dev/null +++ b/app/src/test/java/pl/lambada/songsync/util/ProviderOrderParsingTest.kt @@ -0,0 +1,40 @@ +package pl.lambada.songsync.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.lambada.songsync.data.parseDisabledProviders +import pl.lambada.songsync.data.parseProviderOrder + +/** Round-trip/robustness tests for the persisted provider fallback order (issue #6). */ +class ProviderOrderParsingTest { + + @Test + fun `empty value yields the default order`() { + assertEquals(defaultProviderFallbackOrder, parseProviderOrder("")) + } + + @Test + fun `stored order is preserved and new providers are appended`() { + val stored = "QQMUSIC,LRCLIB" + val parsed = parseProviderOrder(stored) + assertEquals(Providers.QQMUSIC, parsed[0]) + assertEquals(Providers.LRCLIB, parsed[1]) + // Everything else is still present exactly once. + assertEquals(Providers.entries.size, parsed.size) + assertEquals(parsed.size, parsed.distinct().size) + } + + @Test + fun `unknown names are dropped`() { + val parsed = parseProviderOrder("MUSIXMATCH,LRCLIB,GARBAGE") + assertEquals(Providers.LRCLIB, parsed[0]) + assertEquals(Providers.entries.size, parsed.size) + } + + @Test + fun `disabled providers parse and ignore junk`() { + assertEquals(setOf(Providers.SPOTIFY, Providers.QQMUSIC), parseDisabledProviders("SPOTIFY,QQMUSIC,NOPE")) + assertTrue(parseDisabledProviders("").isEmpty()) + } +} diff --git a/app/src/test/java/pl/lambada/songsync/util/TtmlLyricsParserTest.kt b/app/src/test/java/pl/lambada/songsync/util/TtmlLyricsParserTest.kt new file mode 100644 index 0000000..ce6deeb --- /dev/null +++ b/app/src/test/java/pl/lambada/songsync/util/TtmlLyricsParserTest.kt @@ -0,0 +1,104 @@ +package pl.lambada.songsync.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** JVM tests for the TTML parser behind the BetterLyrics provider. */ +class TtmlLyricsParserTest { + + private val wordSyncTtml = """ + + +

+

+ Hello world +

+

+ Second line +

+
+ + + """.trimIndent() + + private val lineSyncTtml = """ + +
+

Plain line text

+
+
+ """.trimIndent() + + @Test + fun `parses word-level spans with times`() { + val lines = TtmlLyricsParser.parse(wordSyncTtml) + assertEquals(2, lines.size) + assertEquals(1000L, lines[0].beginMs) + assertEquals(listOf("Hello", "world"), lines[0].words.map { it.text }) + assertEquals(1500L, lines[0].words[1].beginMs) + assertEquals("Hello world", lines[0].text) + assertEquals("v1", lines[0].agent) + assertEquals("v2", lines[1].agent) + } + + @Test + fun `line-only ttml keeps text without words`() { + val lines = TtmlLyricsParser.parse(lineSyncTtml) + assertEquals(1, lines.size) + assertEquals(12500L, lines[0].beginMs) + assertEquals("Plain line text", lines[0].text) + assertTrue(lines[0].words.isEmpty()) + } + + @Test + fun `toLrc emits line stamps and inline word timing`() { + val lrc = TtmlLyricsParser.toLrc(TtmlLyricsParser.parse(wordSyncTtml))!! + val lines = lrc.lines() + assertEquals(2, lines.size) + assertTrue(lines[0].startsWith("[00:01.000]")) + assertTrue(lines[0].contains("<00:01.000>Hello")) + assertTrue(lines[0].contains("<00:01.500>world")) + assertTrue(lines[1].startsWith("[00:04.250]")) + } + + @Test + fun `toLrc tags voices only in multi-person mode`() { + val parsed = TtmlLyricsParser.parse(wordSyncTtml) + val plain = TtmlLyricsParser.toLrc(parsed, multiPersonWordByWord = false)!! + val multi = TtmlLyricsParser.toLrc(parsed, multiPersonWordByWord = true)!! + assertTrue(!plain.contains("v1:") && !plain.contains("v2:")) + assertTrue(multi.lines()[0].startsWith("[00:01.000]v1:")) + assertTrue(multi.lines()[1].startsWith("[00:04.250]v2:")) + } + + @Test + fun `time expressions parse across formats`() { + assertEquals(1500L, TtmlLyricsParser.parseTimeMs("00:01.5")) + assertEquals(3_723_000L, TtmlLyricsParser.parseTimeMs("01:02:03")) + assertEquals(12_340L, TtmlLyricsParser.parseTimeMs("12.34s")) + assertEquals(1234L, TtmlLyricsParser.parseTimeMs("1234ms")) + assertEquals(9226L, TtmlLyricsParser.parseTimeMs("9.226")) + assertNull(TtmlLyricsParser.parseTimeMs("not-a-time")) + } + + @Test + fun `syllable spans without whitespace merge into one word`() { + val ttml = """ +
+

Crazy town

+
+ """.trimIndent() + val lines = TtmlLyricsParser.parse(ttml) + assertEquals(listOf("Crazy", "town"), lines[0].words.map { it.text }) + assertEquals(1000L, lines[0].words[0].beginMs) + assertEquals(1800L, lines[0].words[0].endMs) + } + + @Test + fun `broken xml returns empty instead of throwing`() { + assertTrue(TtmlLyricsParser.parse("