diff --git a/app/build.gradle b/app/build.gradle index 9c1ae0ffe..236411503 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -185,6 +185,10 @@ dependencies { implementation 'com.google.android.gms:play-services-nearby:19.3.0' implementation 'com.google.zxing:core:3.5.4' + // User-initiated, on-device lyric translation + implementation 'com.google.mlkit:language-id:17.0.6' + implementation 'com.google.mlkit:translate:17.0.3' + // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/LyricsDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/LyricsDialog.kt index 338815089..7b2674f34 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/LyricsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/LyricsDialog.kt @@ -44,6 +44,7 @@ import org.oxycblt.auxio.databinding.DialogLyricsBinding import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.shippy.lyrics.PlainLyrics import org.oxycblt.auxio.shippy.lyrics.SyncedLyrics +import org.oxycblt.auxio.shippy.lyrics.TranslatedLyrics import org.oxycblt.auxio.shippy.provider.ui.ProviderTrackActionsSheet import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment import org.oxycblt.auxio.util.collectImmediately @@ -56,6 +57,7 @@ class LyricsDialog : private val playbackModel: PlaybackViewModel by activityViewModels() private val playerActionsModel: PlayerActionsViewModel by viewModels() private var renderedState: PlaybackLyricsState = PlaybackLyricsState.None + private var renderedTranslationState: LyricsTranslationState = LyricsTranslationState.Hidden private var renderedLineIndex = Int.MIN_VALUE override fun onCreateBinding(inflater: LayoutInflater) = DialogLyricsBinding.inflate(inflater) @@ -73,7 +75,12 @@ class LyricsDialog : binding.lyricsSeekBar.listener = this binding.lyricsBody.highlightColor = Color.TRANSPARENT collectImmediately(playbackModel.displayItem, ::updateTrack) - collectImmediately(playbackModel.lyrics, playbackModel.positionDs, ::updateLyrics) + collectImmediately( + playbackModel.lyrics, + playbackModel.lyricsTranslation, + playbackModel.positionDs, + ::updateLyrics, + ) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playerActionsModel.state, ::updateActions) } @@ -89,6 +96,7 @@ class LyricsDialog : override fun onDestroyBinding(binding: DialogLyricsBinding) { binding.lyricsSeekBar.listener = null renderedState = PlaybackLyricsState.None + renderedTranslationState = LyricsTranslationState.Hidden renderedLineIndex = Int.MIN_VALUE super.onDestroyBinding(binding) } @@ -147,15 +155,21 @@ class LyricsDialog : } } - private fun updateLyrics(state: PlaybackLyricsState, positionDs: Long) { + private fun updateLyrics( + state: PlaybackLyricsState, + translationState: LyricsTranslationState, + positionDs: Long, + ) { val binding = requireBinding() binding.lyricsSeekBar.positionDs = positionDs - val stateChanged = state != renderedState + val stateChanged = state != renderedState || translationState != renderedTranslationState if (stateChanged) { renderedState = state + renderedTranslationState = translationState renderedLineIndex = Int.MIN_VALUE binding.lyricsRetry.isVisible = false binding.lyricsRetry.setOnClickListener(null) + updateTranslationAction(binding, state, translationState) } when (state) { @@ -178,15 +192,13 @@ class LyricsDialog : val activeIndex = activeLyricIndex(lyrics, positionDs * 100) if (stateChanged || activeIndex != renderedLineIndex) { renderedLineIndex = activeIndex - renderSyncedLyrics(binding, lyrics, activeIndex) + renderSyncedLyrics(binding, lyrics, translationState, activeIndex) } } is PlainLyrics -> { if (stateChanged) { binding.lyricsBody.movementMethod = null - binding.lyricsBody.text = - lyrics.plainText.takeIf(String::isNotBlank) - ?: getString(R.string.lng_lyrics_instrumental) + renderPlainLyrics(binding, lyrics, translationState) } } } @@ -208,23 +220,57 @@ class LyricsDialog : private fun renderSyncedLyrics( binding: DialogLyricsBinding, lyrics: SyncedLyrics, + translationState: LyricsTranslationState, activeIndex: Int, ) { val text = SpannableStringBuilder() var activeStart = -1 val activeColor = requireContext().getAttrColorCompat(MR.attr.colorOnSurface).defaultColor val inactiveColor = ColorUtils.setAlphaComponent(activeColor, 0x78) + val translatedLines = + ((translationState as? LyricsTranslationState.Ready)?.takeIf { it.visible }?.lyrics + as? TranslatedLyrics.Synced) + ?.lines lyrics.lines.forEachIndexed { index, line -> if (index > 0) text.append("\n\n") val start = text.length text.append(line.text) + val originalEnd = text.length + val translatedText = translatedLines?.getOrNull(index)?.translatedText.orEmpty() + val translatedStart = + if (translatedText.isNotBlank()) { + text.append("\n") + text.length + } else { + -1 + } + if (translatedStart >= 0) text.append(translatedText) val end = text.length text.setSpan( ForegroundColorSpan(if (index == activeIndex) activeColor else inactiveColor), start, - end, + originalEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, ) + if (translatedStart >= 0) { + text.setSpan( + ForegroundColorSpan( + ColorUtils.setAlphaComponent( + activeColor, + if (index == activeIndex) 0xB8 else 0x68, + ) + ), + translatedStart, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + text.setSpan( + RelativeSizeSpan(0.78f), + translatedStart, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } text.setSpan( object : ClickableSpan() { override fun onClick(widget: View) { @@ -244,20 +290,20 @@ class LyricsDialog : text.setSpan( StyleSpan(Typeface.BOLD), start, - end, + originalEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, ) text.setSpan( RelativeSizeSpan(1.22f), start, - end, + originalEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, ) } else { text.setSpan( RelativeSizeSpan(0.92f), start, - end, + originalEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, ) } @@ -275,6 +321,114 @@ class LyricsDialog : } } + private fun renderPlainLyrics( + binding: DialogLyricsBinding, + lyrics: PlainLyrics, + translationState: LyricsTranslationState, + ) { + if (lyrics.plainText.isBlank()) { + binding.lyricsBody.setText(R.string.lng_lyrics_instrumental) + return + } + val translated = + ((translationState as? LyricsTranslationState.Ready)?.takeIf { it.visible }?.lyrics + as? TranslatedLyrics.Plain) + ?.lines + if (translated == null) { + binding.lyricsBody.text = lyrics.plainText + return + } + val activeColor = requireContext().getAttrColorCompat(MR.attr.colorOnSurface).defaultColor + val text = SpannableStringBuilder() + translated.forEachIndexed { index, line -> + if (index > 0) text.append("\n\n") + text.append(line.originalText) + if (line.translatedText.isNotBlank()) { + text.append("\n") + val start = text.length + text.append(line.translatedText) + text.setSpan( + ForegroundColorSpan(ColorUtils.setAlphaComponent(activeColor, 0x78)), + start, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + text.setSpan( + RelativeSizeSpan(0.78f), + start, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + binding.lyricsBody.text = text + } + + private fun updateTranslationAction( + binding: DialogLyricsBinding, + lyricsState: PlaybackLyricsState, + state: LyricsTranslationState, + ) { + val hasLyrics = + (lyricsState as? PlaybackLyricsState.Ready)?.lyrics?.plainText?.isNotBlank() == true + binding.lyricsTranslate.isVisible = hasLyrics + binding.lyricsTranslationStatus.isVisible = hasLyrics + binding.lyricsTranslate.setOnClickListener(null) + when (state) { + LyricsTranslationState.Hidden -> { + binding.lyricsTranslate.isEnabled = true + binding.lyricsTranslate.setText(R.string.lbl_translate_lyrics) + binding.lyricsTranslate.setOnClickListener { + playbackModel.translateLyricsToEnglish() + } + binding.lyricsTranslationStatus.setText( + R.string.lng_translation_download_disclosure + ) + } + is LyricsTranslationState.Loading -> { + binding.lyricsTranslate.isEnabled = false + binding.lyricsTranslate.setText(R.string.lng_translation_loading) + binding.lyricsTranslationStatus.setText( + R.string.lng_translation_download_disclosure + ) + } + is LyricsTranslationState.Ready -> { + binding.lyricsTranslate.isEnabled = true + binding.lyricsTranslate.setText( + if (state.visible) { + R.string.lbl_hide_translation + } else { + R.string.lbl_show_translation + } + ) + binding.lyricsTranslate.setOnClickListener { + playbackModel.toggleLyricsTranslationVisibility() + } + binding.lyricsTranslationStatus.setText(R.string.lng_translation_attribution) + } + is LyricsTranslationState.AlreadyEnglish -> { + binding.lyricsTranslate.isEnabled = false + binding.lyricsTranslate.setText(R.string.lbl_translate_lyrics) + binding.lyricsTranslationStatus.setText( + R.string.lng_translation_already_english + ) + } + is LyricsTranslationState.Unsupported -> { + binding.lyricsTranslate.isEnabled = false + binding.lyricsTranslate.setText(R.string.lbl_translate_lyrics) + binding.lyricsTranslationStatus.setText(R.string.lng_translation_unsupported) + } + is LyricsTranslationState.Error -> { + binding.lyricsTranslate.isEnabled = true + binding.lyricsTranslate.setText(R.string.lbl_retry_translation) + binding.lyricsTranslate.setOnClickListener { + playbackModel.translateLyricsToEnglish() + } + binding.lyricsTranslationStatus.setText(R.string.lng_translation_error) + } + } + } + companion object { private const val TAG = "lyrics_dialog" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index e99a20352..c85a8bdb6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext @@ -46,7 +47,10 @@ import org.oxycblt.auxio.shippy.lyrics.LyricsLookupResult import org.oxycblt.auxio.shippy.lyrics.LyricsRecord import org.oxycblt.auxio.shippy.lyrics.LyricsRepository import org.oxycblt.auxio.shippy.lyrics.LyricsRequest +import org.oxycblt.auxio.shippy.lyrics.LyricsTranslationResult +import org.oxycblt.auxio.shippy.lyrics.LyricsTranslator import org.oxycblt.auxio.shippy.lyrics.ParsedLyrics +import org.oxycblt.auxio.shippy.lyrics.TranslatedLyrics import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.musikr.Album @@ -74,9 +78,11 @@ constructor( private val listSettings: ListSettings, private val playbackDisplayMapper: PlaybackDisplayMapper, private val lyricsRepository: LyricsRepository, + private val lyricsTranslator: LyricsTranslator, ) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener { private var lastPositionJob: Job? = null private var lyricsJob: Job? = null + private var lyricsTranslationJob: Job? = null private var queueMappingJob: Job? = null private val _song = MutableStateFlow(null) @@ -94,6 +100,11 @@ constructor( val lyrics: StateFlow get() = _lyrics + private val _lyricsTranslation = + MutableStateFlow(LyricsTranslationState.Hidden) + val lyricsTranslation: StateFlow + get() = _lyricsTranslation + private val _parent = MutableStateFlow(null) /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ val parent: StateFlow = _parent @@ -306,16 +317,69 @@ constructor( } fun retryLyrics() { + lyricsTranslationJob?.cancel() + _lyricsTranslation.value = LyricsTranslationState.Hidden _displayItem.value?.let { updateLyrics(it, force = true) } } + fun translateLyricsToEnglish() { + val lyricsState = _lyrics.value as? PlaybackLyricsState.Ready ?: return + if (lyricsState.lyrics.plainText.isBlank()) return + val existing = _lyricsTranslation.value + if (existing is LyricsTranslationState.Ready && existing.trackId == lyricsState.trackId) { + _lyricsTranslation.value = existing.copy(visible = true) + return + } + lyricsTranslationJob?.cancel() + _lyricsTranslation.value = LyricsTranslationState.Loading(lyricsState.trackId) + lyricsTranslationJob = + viewModelScope.launch { + try { + val result = lyricsTranslator.translateToEnglish(lyricsState.lyrics) + if (_displayItem.value?.queueItem?.track?.id != lyricsState.trackId) { + return@launch + } + _lyricsTranslation.value = + when (result) { + is LyricsTranslationResult.Translated -> + LyricsTranslationState.Ready( + trackId = lyricsState.trackId, + sourceLanguage = result.sourceLanguage, + lyrics = result.lyrics, + visible = true, + ) + LyricsTranslationResult.AlreadyEnglish -> + LyricsTranslationState.AlreadyEnglish(lyricsState.trackId) + LyricsTranslationResult.UnsupportedLanguage -> + LyricsTranslationState.Unsupported(lyricsState.trackId) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (_displayItem.value?.queueItem?.track?.id == lyricsState.trackId) { + _lyricsTranslation.value = + LyricsTranslationState.Error(lyricsState.trackId, error.message) + } + } + } + } + + fun toggleLyricsTranslationVisibility() { + val ready = _lyricsTranslation.value as? LyricsTranslationState.Ready ?: return + _lyricsTranslation.value = ready.copy(visible = !ready.visible) + } + private fun updateDisplayItem(item: PlaybackDisplayItem?) { val previousTrackId = _displayItem.value?.queueItem?.track?.id _displayItem.value = item if (item == null) { lyricsJob?.cancel() + lyricsTranslationJob?.cancel() _lyrics.value = PlaybackLyricsState.None + _lyricsTranslation.value = LyricsTranslationState.Hidden } else if (item.queueItem.track.id != previousTrackId) { + lyricsTranslationJob?.cancel() + _lyricsTranslation.value = LyricsTranslationState.Hidden updateLyrics(item, force = false) } } @@ -896,3 +960,22 @@ sealed interface PlaybackLyricsState { data class Error(val trackId: TrackId, val retryable: Boolean, val message: String?) : PlaybackLyricsState } + +sealed interface LyricsTranslationState { + data object Hidden : LyricsTranslationState + + data class Loading(val trackId: TrackId) : LyricsTranslationState + + data class Ready( + val trackId: TrackId, + val sourceLanguage: String, + val lyrics: TranslatedLyrics, + val visible: Boolean, + ) : LyricsTranslationState + + data class AlreadyEnglish(val trackId: TrackId) : LyricsTranslationState + + data class Unsupported(val trackId: TrackId) : LyricsTranslationState + + data class Error(val trackId: TrackId, val message: String?) : LyricsTranslationState +} diff --git a/app/src/main/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslator.kt b/app/src/main/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslator.kt new file mode 100644 index 000000000..c51858b6b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslator.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Auxio Project + * LyricsTranslator.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.oxycblt.auxio.shippy.lyrics + +import com.google.android.gms.tasks.Task +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.TranslatorOptions +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +interface LyricsTranslator { + suspend fun translateToEnglish(lyrics: ParsedLyrics): LyricsTranslationResult +} + +sealed interface LyricsTranslationResult { + data class Translated(val sourceLanguage: String, val lyrics: TranslatedLyrics) : + LyricsTranslationResult + + data object AlreadyEnglish : LyricsTranslationResult + + data object UnsupportedLanguage : LyricsTranslationResult +} + +sealed interface TranslatedLyrics { + data class Plain(val lines: List) : TranslatedLyrics + + data class Synced(val lines: List) : TranslatedLyrics +} + +data class TranslatedLyricLine(val originalText: String, val translatedText: String) + +data class TranslatedSyncedLyricLine( + val startMs: Long, + val originalText: String, + val translatedText: String, +) + +@Singleton +class MlKitLyricsTranslator @Inject constructor() : LyricsTranslator { + override suspend fun translateToEnglish(lyrics: ParsedLyrics): LyricsTranslationResult { + val sourceText = lyrics.plainText.trim() + if (sourceText.isBlank()) return LyricsTranslationResult.UnsupportedLanguage + + val languageIdentifier = LanguageIdentification.getClient() + val languageTag = + try { + languageIdentifier.identifyLanguage(sourceText).awaitValue() + } finally { + languageIdentifier.close() + } + if (languageTag == TranslateLanguage.ENGLISH) return LyricsTranslationResult.AlreadyEnglish + val sourceLanguage = + TranslateLanguage.fromLanguageTag(languageTag) + ?: return LyricsTranslationResult.UnsupportedLanguage + + val options = + TranslatorOptions.Builder() + .setSourceLanguage(sourceLanguage) + .setTargetLanguage(TranslateLanguage.ENGLISH) + .build() + val translator = Translation.getClient(options) + return try { + translator + .downloadModelIfNeeded(DownloadConditions.Builder().build()) + .awaitValue() + val translated = + translateLyricsWith(lyrics) { text -> translator.translate(text).awaitValue() } + LyricsTranslationResult.Translated(languageTag, translated) + } finally { + translator.close() + } + } +} + +internal suspend fun translateLyricsWith( + lyrics: ParsedLyrics, + translate: suspend (String) -> String, +): TranslatedLyrics { + val originals = + when (lyrics) { + is PlainLyrics -> lyrics.plainText.lines() + is SyncedLyrics -> lyrics.lines.map(SyncedLyricLine::text) + } + val translations = mutableMapOf() + originals.distinct().filter(String::isNotBlank).forEach { original -> + translations[original] = translate(original) + } + return when (lyrics) { + is PlainLyrics -> + TranslatedLyrics.Plain( + originals.map { original -> + TranslatedLyricLine(original, translations[original].orEmpty()) + } + ) + is SyncedLyrics -> + TranslatedLyrics.Synced( + lyrics.lines.map { line -> + TranslatedSyncedLyricLine( + startMs = line.startMs, + originalText = line.text, + translatedText = translations[line.text].orEmpty(), + ) + } + ) + } +} + +private suspend fun Task.awaitValue(): T = + suspendCancellableCoroutine { continuation -> + addOnSuccessListener { value -> + if (continuation.isActive) continuation.resume(value) + } + addOnFailureListener { error -> + if (continuation.isActive) continuation.resumeWithException(error) + } + addOnCanceledListener { continuation.cancel() } + } + +@Module +@InstallIn(SingletonComponent::class) +abstract class LyricsTranslationModule { + @Binds + @Singleton + abstract fun translator(translator: MlKitLyricsTranslator): LyricsTranslator +} diff --git a/app/src/main/res/layout/dialog_lyrics.xml b/app/src/main/res/layout/dialog_lyrics.xml index 0733248e0..9b827d346 100644 --- a/app/src/main/res/layout/dialog_lyrics.xml +++ b/app/src/main/res/layout/dialog_lyrics.xml @@ -58,6 +58,28 @@ + + + + Lyrics are not available for this track. Lyrics could not be loaded. Instrumental + Translate to English + Hide translation + Show translation + Retry translation + Preparing English translation… + On-device translation by Google may download a language model of about 30 MB. + Translated by Google + These lyrics are already in English. + The lyrics language could not be translated. + Translation could not be completed. Original lyrics are still available. Indexing local music… Playlist No tracks here yet. diff --git a/app/src/test/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslatorTest.kt b/app/src/test/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslatorTest.kt new file mode 100644 index 000000000..fdd5bb1a2 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/shippy/lyrics/LyricsTranslatorTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Auxio Project + * LyricsTranslatorTest.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.oxycblt.auxio.shippy.lyrics + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class LyricsTranslatorTest { + @Test + fun `synced translation preserves timestamps order and duplicate lines`() = runBlocking { + val calls = mutableListOf() + val original = + SyncedLyrics( + lines = + listOf( + SyncedLyricLine(1_000, "तुम ही हो"), + SyncedLyricLine(2_500, "अब तुम ही हो"), + SyncedLyricLine(4_000, "तुम ही हो"), + ), + plainText = "तुम ही हो\nअब तुम ही हो", + ) + + val translated = + translateLyricsWith(original) { line -> + calls += line + mapOf("तुम ही हो" to "You are the one", "अब तुम ही हो" to "Now you are the one") + .getValue(line) + } as TranslatedLyrics.Synced + + assertEquals(listOf("तुम ही हो", "अब तुम ही हो"), calls) + assertEquals(listOf(1_000L, 2_500L, 4_000L), translated.lines.map { it.startMs }) + assertEquals( + listOf("You are the one", "Now you are the one", "You are the one"), + translated.lines.map { it.translatedText }, + ) + } + + @Test + fun `plain translation retains original lines and blanks`() = runBlocking { + val translated = + translateLyricsWith(PlainLyrics("पहली पंक्ति\n\nदूसरी पंक्ति")) { "English: $it" } + as TranslatedLyrics.Plain + + assertEquals( + listOf("पहली पंक्ति", "", "दूसरी पंक्ति"), + translated.lines.map { it.originalText }, + ) + assertEquals( + listOf("English: पहली पंक्ति", "", "English: दूसरी पंक्ति"), + translated.lines.map { it.translatedText }, + ) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4aa64fc15..e137bb5db 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -131,6 +131,7 @@ shippy/lyrics musixmatch broker boundary lrclib parser + on-device English translator Room-backed offline cache shippy/lastfm shippy/share @@ -446,6 +447,16 @@ the hit, plain/synchronized payloads, instrumental state, and fetch time. A TrackId hit is rejected if the cached recording metadata no longer matches the request. Lyrics do not require a separate encryption dependency. +English translation is a separate, user-initiated boundary after parsing. +`LyricsTranslator` detects the dominant language of the combined original text +and uses ML Kit's on-device translation model. It never receives raw LRC +timestamps. Repeated nonblank lines are translated once, then reconstructed in +the original order with every `startMs` preserved exactly. `PlaybackViewModel` +owns the current-track translation job and presentation state, cancels it on a +track change, and rejects stale results before publication. The current-track +result is retained for hide/show without retranslating; generated translations +do not alter `LyricsRecord` or the Room lyrics cache. + ### Last.fm Scrobbling is an optional playback observer, not a second player state. It diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 4e2aa88c3..8feaa318c 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -154,6 +154,11 @@ code/test level, ready for later device verification. ## Stage 4 — Player Integrations And Android Polish - [x] Lyrics retrieval, synchronization, caching, and offline use. +- [x] Add explicitly user-initiated, on-device English lyric translation. + - [x] Preserve original text, synchronized timestamps, active-line scrolling, + and tap-to-seek while rendering English beneath each source line. + - [x] Deduplicate repeated lines, cancel stale track work, retain current-track + hide/show state, and disclose the approximately 30 MB model download. - [x] Last.fm authentication boundary and scrobbling. - [x] Signed API client, encrypted credential repository, durable FIFO outbox, listen policy, and playback lifecycle observer. diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index d95437add..ed2066f46 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -72,8 +72,9 @@ These decisions came directly from the product owner. optional hosted relay for reliability and larger groups. 21. The complete application is the target. Engineering may proceed in stages, but staging must not quietly remove final product requirements. -22. AI is not part of the product unless a later explicit decision adds a - narrowly useful feature. +22. AI is not part of the product except for the explicitly approved, + user-initiated on-device translation of loaded lyrics into English. This + utility does not introduce AI branding, chat, DJ, or creation behavior. 23. Full chat and a general-purpose social network are not part of Crew. 24. Accessibility implementation is a release-hardening pass after the functional application and product polish are complete. It remains required @@ -414,7 +415,9 @@ Required product integrations: - Lyrics through an ordered provider chain, with synchronized lyrics where available and cached offline behavior. Musixmatch may be preferred only through an official securely supplied credential or broker; no reusable key is embedded - in the open-source APK. LRCLIB is the no-key fallback. + in the open-source APK. LRCLIB is the no-key fallback. Loaded non-English lyrics + may be translated to English only after an explicit user action; original text + and synchronized timestamps remain authoritative and visible. - Last.fm authentication and scrobbling, followed by relevant discovery/stats - Equalizer and audio settings - Playlist import/export where implementations are reliable diff --git a/docs/STATUS.md b/docs/STATUS.md index 60cc18e54..d16e6c74d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,6 +8,19 @@ Read this file after `PRODUCT_SPEC.md` whenever work resumes. This status is evidence-based: source presence is not counted as verified device behavior. +**Unreleased English lyric translation slice:** The full lyrics sheet now +offers manual on-device English translation for loaded non-instrumental lyrics, +with original-first bilingual rendering, model-download disclosure, Google +attribution, and honest loading/already-English/unsupported/error states. +Synchronized timestamps, order, active-line behavior, scrolling, and +tap-to-seek remain attached to each original/translated pair. Repeated lines +translate once. `PlaybackViewModel` cancels work on track changes, rejects stale +results, and retains the current-track result for hide/show without modifying +the original Room lyrics cache. Focused deterministic tests were added; +`dialog_lyrics.xml` parses successfully and `git diff --check` passes. Gradle +formatting, compilation, JVM tests, and device behavior are not verified because +this machine has no Java runtime configured. + ## Implemented in the current pass - Typed playback mutations separate user intent, Crew projection, and local diff --git a/docs/UX.md b/docs/UX.md index ba1663ad0..a87301864 100644 --- a/docs/UX.md +++ b/docs/UX.md @@ -234,6 +234,18 @@ Download. Tapping a timestamped synchronized line seeks playback to that line and immediately updates the progress presentation; plain lyrics are not misrepresented as seekable. +The full lyrics surface exposes **Translate to English** only for loaded, +non-instrumental lyrics. Translation is manual and never begins merely because a +track starts. Before translation begins, the surface explains that on-device +translation by Google may download a language model of about 30 MB. Original +lyrics remain primary and the English translation appears immediately beneath +each corresponding line in smaller secondary typography. Synchronized +timestamps, active-line behavior, scrolling, and tap-to-seek stay attached to +the original line pair. A completed translation can be hidden and shown without +running translation again. Already-English, unsupported-language, loading, and +retryable failure states are honest; no translation state replaces or hides the +original lyrics. + ### Overflow Allowed: