From ec9f9271579a0b002312d0f51d73ff5015017177 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 22 Jun 2026 19:25:05 +0200 Subject: [PATCH] new: add subtitle translations --- .../subtitle/data/LrcSubtitleRepository.kt | 20 ++++- .../gwenedeg/subtitle/domain/SubtitleLine.kt | 6 +- .../subtitle/domain/SubtitleRepository.kt | 9 +- .../kotlin/fr/ajaury/gwenedeg/App.kt | 3 + .../fr/ajaury/gwenedeg/navigation/Route.kt | 1 + .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 14 +-- .../ajaury/gwenedeg/player/ui/SubtitleList.kt | 90 +++++++++++++++---- .../player/ui/viewmodel/PlayerViewModel.kt | 12 ++- .../records/data/InMemoryRecordRepository.kt | 1 + .../ajaury/gwenedeg/records/domain/Record.kt | 1 + 10 files changed, 129 insertions(+), 28 deletions(-) diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt index a9e438f..f112845 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt @@ -12,14 +12,30 @@ internal class LrcSubtitleRepository( private val dispatcherProvider: DispatcherProvider, private val logger: Logger, ) : SubtitleRepository { - override suspend fun getSubtitle(content: String): Subtitle = + override suspend fun getSubtitle( + content: String, + translationContent: String?, + ): Subtitle = withContext(dispatcherProvider.default) { try { - parser.parse(content = content) + val subtitle = parser.parse(content = content) + val translation = translationContent?.let { parser.parse(content = it) } + subtitle.mergeTranslation(translation) } catch (exception: Exception) { val message = "Failed to load subtitle: $content" logger.error(message = message, throwable = exception) throw CantGetSubtitleException(message) } } + + /** Attaches each [translation] line's text to the matching original line, paired by timestamp. */ + private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle { + if (translation == null) return this + val translationByStart = translation.lines.associate { it.startMs to it.text } + return Subtitle( + lines = lines.map { line -> + line.copy(translation = translationByStart[line.startMs]) + }, + ) + } } diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt index d66b340..0f20866 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt @@ -1,7 +1,11 @@ package fr.ajaury.gwenedeg.subtitle.domain -/** A single timed subtitle cue: [text] becomes active at [startMs]. */ +/** + * A single timed subtitle cue: [text] becomes active at [startMs]. [translation] is the optional + * translation of [text] in the user's language (e.g. French), shown alongside the original. + */ data class SubtitleLine( val startMs: Long, val text: String, + val translation: String? = null, ) diff --git a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt index 4427d09..864dce2 100644 --- a/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt +++ b/data/subtitle/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt @@ -1,5 +1,12 @@ package fr.ajaury.gwenedeg.subtitle.domain interface SubtitleRepository { - suspend fun getSubtitle(content: String): Subtitle + /** + * Parses the subtitle [content]. When [translationContent] is provided, each line is matched to + * its translation by timestamp. + */ + suspend fun getSubtitle( + content: String, + translationContent: String? = null, + ): Subtitle } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt index 2ff69e3..2ae983e 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt @@ -52,6 +52,8 @@ fun App() { title = record.title, audioResourcePath = record.audioResourcePath, subtitleResourcePath = record.subtitleResourcePath, + translationSubtitleResourcePath = + record.translationSubtitleResourcePath, ), ) }, @@ -63,6 +65,7 @@ fun App() { recordTitle = key.title, audioResourcePath = key.audioResourcePath, subtitleResourcePath = key.subtitleResourcePath, + translationSubtitleResourcePath = key.translationSubtitleResourcePath, onBackClicked = { backStack.removeLastOrNull() }, ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt index 84f1ffc..2144337 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt @@ -13,5 +13,6 @@ sealed interface Route : NavKey { val title: String, val audioResourcePath: String, val subtitleResourcePath: String, + val translationSubtitleResourcePath: String? = null, ) : Route } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt index e3c8a1c..d183202 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt @@ -34,14 +34,18 @@ fun PlayerScreen( recordTitle: String, audioResourcePath: String, subtitleResourcePath: String, + translationSubtitleResourcePath: String? = null, onBackClicked: () -> Unit = {}, viewModel: PlayerViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - DisposableEffect(audioResourcePath, subtitleResourcePath) { + DisposableEffect(audioResourcePath, subtitleResourcePath, translationSubtitleResourcePath) { viewModel.play(filePath = audioResourcePath) - viewModel.loadSubtitle(resourcePath = subtitleResourcePath) + viewModel.loadSubtitle( + resourcePath = subtitleResourcePath, + translationResourcePath = translationSubtitleResourcePath, + ) onDispose { viewModel.stop() } @@ -115,9 +119,9 @@ fun PlayerScreen( private val previewSubtitleLines = listOf( - SubtitleLine(startMs = 0, text = "Demat deoc'h !"), - SubtitleLine(startMs = 4310, text = "Kenavo !"), - SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), + SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), + SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), + SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), ) @Preview diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt index 6182323..81ae8db 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +23,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -29,7 +31,6 @@ import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme import kotlin.math.abs - @Composable fun SubtitleList( modifier: Modifier = Modifier, @@ -47,7 +48,11 @@ fun SubtitleList( -1 } else { val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f - info.visibleItemsInfo.minByOrNull { abs((it.offset + it.size / 2f) - viewportCenter) }!!.index + info.visibleItemsInfo + .minByOrNull { + abs((it.offset + it.size / 2f) - viewportCenter) + }?.index + ?: -1 } } } @@ -115,18 +120,69 @@ private fun Subtitle( line: SubtitleLine, isCurrent: Boolean, ) { + val contentColor = if (isCurrent) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + MainTextLine( + text = line.text, + isCurrent = isCurrent, + contentColor = contentColor, + ) + line.translation?.let { translation -> + TranslationLine( + text = translation, + isCurrent = isCurrent, + contentColor = contentColor, + ) + } + } +} + +@Composable +private fun MainTextLine( + text: String, + isCurrent: Boolean, + contentColor: Color, +) { + val mainTextStyle = if (isCurrent) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.bodyLarge + } + Text( - text = line.text, - style = if (isCurrent) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.bodyLarge - }, - color = if (isCurrent) { - Color.Unspecified - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - }, + text = text, + style = mainTextStyle, + color = contentColor, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun TranslationLine( + text: String, + isCurrent: Boolean, + contentColor: Color, +) { + val translationStyle = if (isCurrent) { + MaterialTheme.typography.bodyLarge + } else { + MaterialTheme.typography.bodySmall + } + + Text( + text = text, + style = translationStyle.copy(fontStyle = FontStyle.Italic), + color = contentColor, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) @@ -134,9 +190,9 @@ private fun Subtitle( private val previewSubtitleLines = listOf( - SubtitleLine(startMs = 0, text = "Demat deoc'h !"), - SubtitleLine(startMs = 4310, text = "Kenavo !"), - SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), + SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"), + SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"), + SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"), ) @Preview @@ -148,4 +204,4 @@ private fun SubtitleListPreview() { currentIndex = 1, ) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index 9765cae..d0342a7 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -93,11 +93,19 @@ class PlayerViewModel( } } - fun loadSubtitle(resourcePath: String) { + fun loadSubtitle( + resourcePath: String, + translationResourcePath: String? = null, + ) { viewModelScope.launch { subtitle.value = try { val content = resourceReader.read(resourcePath = resourcePath) - subtitleRepository.getSubtitle(content = content) + val translationContent = translationResourcePath + ?.let { resourceReader.read(resourcePath = it) } + subtitleRepository.getSubtitle( + content = content, + translationContent = translationContent, + ) } catch (exception: Exception) { logger.error( message = "Failed to load subtitle: $resourcePath", diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt index 1dcfc36..00cbb6e 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt @@ -39,6 +39,7 @@ class InMemoryRecordRepository : RecordRepository { title = title, audioResourcePath = "files/Chom_bev_$paddedNumber.m4a", subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc", + translationSubtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_fr.lrc", ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt index 2fafc9f..fa68e71 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt @@ -5,4 +5,5 @@ data class Record( val title: String, val audioResourcePath: String, val subtitleResourcePath: String, + val translationSubtitleResourcePath: String? = null, )