new: add subtitle translations
This commit is contained in:
+18
-2
@@ -12,14 +12,30 @@ internal class LrcSubtitleRepository(
|
|||||||
private val dispatcherProvider: DispatcherProvider,
|
private val dispatcherProvider: DispatcherProvider,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
) : SubtitleRepository {
|
) : SubtitleRepository {
|
||||||
override suspend fun getSubtitle(content: String): Subtitle =
|
override suspend fun getSubtitle(
|
||||||
|
content: String,
|
||||||
|
translationContent: String?,
|
||||||
|
): Subtitle =
|
||||||
withContext(dispatcherProvider.default) {
|
withContext(dispatcherProvider.default) {
|
||||||
try {
|
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) {
|
} catch (exception: Exception) {
|
||||||
val message = "Failed to load subtitle: $content"
|
val message = "Failed to load subtitle: $content"
|
||||||
logger.error(message = message, throwable = exception)
|
logger.error(message = message, throwable = exception)
|
||||||
throw CantGetSubtitleException(message)
|
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])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
package fr.ajaury.gwenedeg.subtitle.domain
|
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(
|
data class SubtitleLine(
|
||||||
val startMs: Long,
|
val startMs: Long,
|
||||||
val text: String,
|
val text: String,
|
||||||
|
val translation: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,12 @@
|
|||||||
package fr.ajaury.gwenedeg.subtitle.domain
|
package fr.ajaury.gwenedeg.subtitle.domain
|
||||||
|
|
||||||
interface SubtitleRepository {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ fun App() {
|
|||||||
title = record.title,
|
title = record.title,
|
||||||
audioResourcePath = record.audioResourcePath,
|
audioResourcePath = record.audioResourcePath,
|
||||||
subtitleResourcePath = record.subtitleResourcePath,
|
subtitleResourcePath = record.subtitleResourcePath,
|
||||||
|
translationSubtitleResourcePath =
|
||||||
|
record.translationSubtitleResourcePath,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -63,6 +65,7 @@ fun App() {
|
|||||||
recordTitle = key.title,
|
recordTitle = key.title,
|
||||||
audioResourcePath = key.audioResourcePath,
|
audioResourcePath = key.audioResourcePath,
|
||||||
subtitleResourcePath = key.subtitleResourcePath,
|
subtitleResourcePath = key.subtitleResourcePath,
|
||||||
|
translationSubtitleResourcePath = key.translationSubtitleResourcePath,
|
||||||
onBackClicked = { backStack.removeLastOrNull() },
|
onBackClicked = { backStack.removeLastOrNull() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ sealed interface Route : NavKey {
|
|||||||
val title: String,
|
val title: String,
|
||||||
val audioResourcePath: String,
|
val audioResourcePath: String,
|
||||||
val subtitleResourcePath: String,
|
val subtitleResourcePath: String,
|
||||||
|
val translationSubtitleResourcePath: String? = null,
|
||||||
) : Route
|
) : Route
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,18 @@ fun PlayerScreen(
|
|||||||
recordTitle: String,
|
recordTitle: String,
|
||||||
audioResourcePath: String,
|
audioResourcePath: String,
|
||||||
subtitleResourcePath: String,
|
subtitleResourcePath: String,
|
||||||
|
translationSubtitleResourcePath: String? = null,
|
||||||
onBackClicked: () -> Unit = {},
|
onBackClicked: () -> Unit = {},
|
||||||
viewModel: PlayerViewModel = koinViewModel(),
|
viewModel: PlayerViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
DisposableEffect(audioResourcePath, subtitleResourcePath) {
|
DisposableEffect(audioResourcePath, subtitleResourcePath, translationSubtitleResourcePath) {
|
||||||
viewModel.play(filePath = audioResourcePath)
|
viewModel.play(filePath = audioResourcePath)
|
||||||
viewModel.loadSubtitle(resourcePath = subtitleResourcePath)
|
viewModel.loadSubtitle(
|
||||||
|
resourcePath = subtitleResourcePath,
|
||||||
|
translationResourcePath = translationSubtitleResourcePath,
|
||||||
|
)
|
||||||
onDispose {
|
onDispose {
|
||||||
viewModel.stop()
|
viewModel.stop()
|
||||||
}
|
}
|
||||||
@@ -115,9 +119,9 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
private val previewSubtitleLines =
|
private val previewSubtitleLines =
|
||||||
listOf(
|
listOf(
|
||||||
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
|
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"),
|
||||||
SubtitleLine(startMs = 4310, text = "Kenavo !"),
|
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"),
|
||||||
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
|
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.gestures.animateScrollBy
|
|||||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -22,6 +23,7 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -29,7 +31,6 @@ import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
|||||||
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SubtitleList(
|
fun SubtitleList(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -47,7 +48,11 @@ fun SubtitleList(
|
|||||||
-1
|
-1
|
||||||
} else {
|
} else {
|
||||||
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f
|
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,
|
line: SubtitleLine,
|
||||||
isCurrent: Boolean,
|
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(
|
||||||
text = line.text,
|
text = text,
|
||||||
style = if (isCurrent) {
|
style = mainTextStyle,
|
||||||
MaterialTheme.typography.headlineSmall
|
color = contentColor,
|
||||||
} else {
|
textAlign = TextAlign.Center,
|
||||||
MaterialTheme.typography.bodyLarge
|
modifier = Modifier.fillMaxWidth(),
|
||||||
},
|
)
|
||||||
color = if (isCurrent) {
|
}
|
||||||
Color.Unspecified
|
|
||||||
} else {
|
@Composable
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
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,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -134,9 +190,9 @@ private fun Subtitle(
|
|||||||
|
|
||||||
private val previewSubtitleLines =
|
private val previewSubtitleLines =
|
||||||
listOf(
|
listOf(
|
||||||
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
|
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"),
|
||||||
SubtitleLine(startMs = 4310, text = "Kenavo !"),
|
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"),
|
||||||
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
|
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|||||||
+10
-2
@@ -93,11 +93,19 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSubtitle(resourcePath: String) {
|
fun loadSubtitle(
|
||||||
|
resourcePath: String,
|
||||||
|
translationResourcePath: String? = null,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
subtitle.value = try {
|
subtitle.value = try {
|
||||||
val content = resourceReader.read(resourcePath = resourcePath)
|
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) {
|
} catch (exception: Exception) {
|
||||||
logger.error(
|
logger.error(
|
||||||
message = "Failed to load subtitle: $resourcePath",
|
message = "Failed to load subtitle: $resourcePath",
|
||||||
|
|||||||
+1
@@ -39,6 +39,7 @@ class InMemoryRecordRepository : RecordRepository {
|
|||||||
title = title,
|
title = title,
|
||||||
audioResourcePath = "files/Chom_bev_$paddedNumber.m4a",
|
audioResourcePath = "files/Chom_bev_$paddedNumber.m4a",
|
||||||
subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc",
|
subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc",
|
||||||
|
translationSubtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_fr.lrc",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ data class Record(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val audioResourcePath: String,
|
val audioResourcePath: String,
|
||||||
val subtitleResourcePath: String,
|
val subtitleResourcePath: String,
|
||||||
|
val translationSubtitleResourcePath: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user