refactor: centralize domain models into core.model module

This commit is contained in:
2026-07-01 17:06:02 +02:00
parent ff1844e49f
commit 657d39a293
28 changed files with 190 additions and 76 deletions
+45
View File
@@ -0,0 +1,45 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
iosArm64()
iosSimulatorArm64()
jvm()
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
androidLibrary {
namespace = "fr.ajaury.gwenedeg.core.model"
compileSdk =
libs.versions.androidCompileSdk
.get()
.toInt()
minSdk =
libs.versions.androidMinSdk
.get()
.toInt()
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
@@ -0,0 +1,10 @@
package fr.ajaury.gwenedeg.core.model
/**
* [translation] is the optional translation of [transcription] in the user's language
* (e.g. French), shown alongside the original.
*/
data class Phrase(
val transcription: String,
val translation: String? = null,
)
@@ -1,4 +1,4 @@
package fr.ajaury.gwenedeg.records.domain
package fr.ajaury.gwenedeg.core.model
data class Record(
val id: Int,
+1
View File
@@ -42,6 +42,7 @@ kotlin {
// Core modules
implementation(projects.core.coroutines)
implementation(projects.core.logging)
implementation(projects.core.model)
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
@@ -1,7 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -14,23 +12,23 @@ internal class LrcParser {
private val timeTag = Regex("""\[(\d{1,2}):(\d{2})[.:](\d{2,3})]""")
private val leadingTimeTags = Regex("""^(?:\[\d{1,2}:\d{2}[.:]\d{2,3}])+""")
fun parse(content: String): Subtitle {
val lines = content
fun parse(content: String): List<TimeCodedText> =
content
.lineSequence()
.flatMap { line -> parseLine(line) }
.sortedBy { it.startTime }
.toList()
return Subtitle(lines)
}
private fun parseLine(line: String): List<SubtitleLine> {
private fun parseLine(line: String): List<TimeCodedText> {
val tags = leadingTimeTags.find(line) ?: return emptyList()
val text = line.substring(tags.range.last + 1).trim()
return timeTag
.findAll(tags.value)
.mapNotNull { match ->
SubtitleLine(startTime = match.toMs().milliseconds, text = text)
.takeIf { it.text.isNotBlank() }
TimeCodedText(
startTime = match.toMs().milliseconds,
text = text,
).takeIf { text.isNotBlank() }
}.toList()
}
@@ -2,9 +2,11 @@ package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.core.coroutines.domain.DispatcherProvider
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import fr.ajaury.gwenedeg.subtitle.model.CantGetSubtitleException
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import kotlinx.coroutines.withContext
internal class LrcSubtitleRepository(
@@ -13,28 +15,33 @@ internal class LrcSubtitleRepository(
private val logger: Logger,
) : SubtitleRepository {
override suspend fun getSubtitle(
content: String,
transcriptionContent: String,
translationContent: String?,
): Subtitle =
withContext(dispatcherProvider.default) {
try {
val subtitle = parser.parse(content = content)
val transcription = parser.parse(content = transcriptionContent)
val translation = translationContent?.let { parser.parse(content = it) }
subtitle.mergeTranslation(translation)
transcription.mergeTranslation(translation)
} catch (exception: Exception) {
val message = "Failed to load subtitle: $content"
val message = "Failed to load subtitle: $transcriptionContent"
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.startTime to it.text }
private fun List<TimeCodedText>.mergeTranslation(translation: List<TimeCodedText>?): Subtitle {
val translationByStart = translation?.associate { it.startTime to it.text } ?: emptyMap()
return Subtitle(
lines = lines.map { line ->
line.copy(translation = translationByStart[line.startTime])
lines = this.map {
SubtitleLine(
startTime = it.startTime,
phrase = Phrase(
transcription = it.text,
translation = translationByStart[it.startTime],
),
)
},
)
}
@@ -0,0 +1,11 @@
package fr.ajaury.gwenedeg.subtitle.data
import kotlin.time.Duration
/**
* A single timed text cue: [text] becomes active at [startTime].
*/
data class TimeCodedText(
val startTime: Duration,
val text: String,
)
@@ -1,5 +1,6 @@
package fr.ajaury.gwenedeg.subtitle.domain
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlin.time.Duration
class GetCurrentSubtitleIndexUseCase {
@@ -1,13 +0,0 @@
package fr.ajaury.gwenedeg.subtitle.domain
import kotlin.time.Duration
/**
* A single timed subtitle cue: [text] becomes active at [startTime]. [translation] is the optional
* translation of [text] in the user's language (e.g. French), shown alongside the original.
*/
data class SubtitleLine(
val startTime: Duration,
val text: String,
val translation: String? = null,
)
@@ -1,12 +1,14 @@
package fr.ajaury.gwenedeg.subtitle.domain
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
interface SubtitleRepository {
/**
* Parses the subtitle [content]. When [translationContent] is provided, each line is matched to
* Parses the subtitle [transcriptionContent]. When [translationContent] is provided, each line is matched to
* its translation by timestamp.
*/
suspend fun getSubtitle(
content: String,
transcriptionContent: String,
translationContent: String? = null,
): Subtitle
}
@@ -1,4 +1,4 @@
package fr.ajaury.gwenedeg.subtitle.domain
package fr.ajaury.gwenedeg.subtitle.model
data class Subtitle(
val lines: List<SubtitleLine>,
@@ -0,0 +1,12 @@
package fr.ajaury.gwenedeg.subtitle.model
import fr.ajaury.gwenedeg.core.model.Phrase
import kotlin.time.Duration
/**
* A single timed subtitle cue: [phrase] becomes active at [startTime].
*/
data class SubtitleLine(
val startTime: Duration,
val phrase: Phrase,
)
@@ -1,8 +1,9 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
@@ -14,9 +15,9 @@ class GetCurrentSubtitleIndexUseCaseTest {
Subtitle(
lines =
listOf(
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !"),
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
),
)
@@ -23,31 +23,31 @@ class LrcParserTest {
@Test
fun ignores_metadata_tags_and_keeps_only_timed_cues() {
val subtitle = parser.parse(sample)
val lines = parser.parse(sample)
assertEquals(3, subtitle.lines.size)
assertEquals(3, lines.size)
}
@Test
fun converts_timestamp_to_milliseconds() {
val subtitle = parser.parse(sample)
val lines = parser.parse(sample)
assertEquals(0.milliseconds, subtitle.lines[0].startTime)
assertEquals(4310.milliseconds, subtitle.lines[1].startTime)
assertEquals(7350.milliseconds, subtitle.lines[2].startTime)
assertEquals(0.milliseconds, lines[0].startTime)
assertEquals(4310.milliseconds, lines[1].startTime)
assertEquals(7350.milliseconds, lines[2].startTime)
}
@Test
fun parses_three_digit_fraction_as_milliseconds() {
val subtitle = parser.parse("[00:01.250]Hello")
val lines = parser.parse("[00:01.250]Hello")
assertEquals(1250.milliseconds, subtitle.lines.single().startTime)
assertEquals(1250.milliseconds, lines.single().startTime)
}
@Test
fun sorts_cues_ascending_by_start() {
val subtitle = parser.parse("[00:05.00]Later\n[00:01.00]Earlier")
val lines = parser.parse("[00:05.00]Later\n[00:01.00]Earlier")
assertEquals(listOf("Earlier", "Later"), subtitle.lines.map { it.text })
assertEquals(listOf("Earlier", "Later"), lines.map { it.text })
}
}
+1
View File
@@ -41,6 +41,7 @@ include(":shared")
include(":core:audioplayer")
include(":core:coroutines")
include(":core:logging")
include(":core:model")
// Data modules
include(":data:subtitle")
+1
View File
@@ -62,6 +62,7 @@ kotlin {
implementation(projects.core.audioplayer)
implementation(projects.core.coroutines)
implementation(projects.core.logging)
implementation(projects.core.model)
// Data modules
implementation(projects.data.subtitle)
@@ -1,15 +1,15 @@
package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.core.model.Record
import fr.ajaury.gwenedeg.player.domain.AudioPlayer
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
@@ -91,7 +91,7 @@ internal class PlaybackRepositoryImpl(
val translationContent = record.translationSubtitleResourcePath
?.let { resourceReader.read(resourcePath = it) }
subtitleRepository.getSubtitle(
content = content,
transcriptionContent = content,
translationContent = translationContent,
)
} catch (exception: Exception) {
@@ -1,9 +1,9 @@
package fr.ajaury.gwenedeg.player.domain
import fr.ajaury.gwenedeg.core.model.Record
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
@@ -22,11 +22,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -133,9 +134,27 @@ fun PlayerScreen(
private val previewSubtitleLines =
listOf(
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
SubtitleLine(
startTime = 0.milliseconds,
phrase = Phrase(
transcription = "Demat deoc'h !",
translation = "Bonjour !",
),
),
SubtitleLine(
startTime = 4310.milliseconds,
phrase = Phrase(
transcription = "Kenavo !",
translation = "Au revoir !",
),
),
SubtitleLine(
startTime = 7350.milliseconds,
phrase = Phrase(
transcription = "Kenavo emberr !",
translation = "À ce soir !",
),
),
)
@Preview
@@ -27,7 +27,8 @@ 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
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
@@ -132,12 +133,12 @@ private fun Subtitle(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
MainTextLine(
text = line.text,
TranscriptionLine(
text = line.phrase.transcription,
isCurrent = isCurrent,
contentColor = contentColor,
)
line.translation?.let { translation ->
line.phrase.translation?.let { translation ->
TranslationLine(
text = translation,
isCurrent = isCurrent,
@@ -148,7 +149,7 @@ private fun Subtitle(
}
@Composable
private fun MainTextLine(
private fun TranscriptionLine(
text: String,
isCurrent: Boolean,
contentColor: Color,
@@ -191,9 +192,27 @@ private fun TranslationLine(
private val previewSubtitleLines =
listOf(
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
SubtitleLine(
startTime = 0.milliseconds,
phrase = Phrase(
transcription = "Demat deoc'h !",
translation = "Bonjour !",
),
),
SubtitleLine(
startTime = 4310.milliseconds,
phrase = Phrase(
transcription = "Kenavo !",
translation = "Au revoir !",
),
),
SubtitleLine(
startTime = 7350.milliseconds,
phrase = Phrase(
transcription = "Kenavo emberr !",
translation = "À ce soir !",
),
),
)
@Preview
@@ -2,7 +2,7 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
data class PlayerUiState(
val recordTitle: String = "",
@@ -3,10 +3,10 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.core.model.Record
import fr.ajaury.gwenedeg.player.domain.AudioSessionManager
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.records.domain.RecordRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -1,7 +1,7 @@
package fr.ajaury.gwenedeg.records.data
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.records.domain.RecordRepository
import fr.ajaury.gwenedeg.core.model.Record
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -1,5 +1,6 @@
package fr.ajaury.gwenedeg.records.domain
import fr.ajaury.gwenedeg.core.model.Record
import kotlinx.coroutines.flow.Flow
interface RecordRepository {
@@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.core.model.Record
@Composable
fun RecordView(
@@ -1,9 +1,7 @@
package fr.ajaury.gwenedeg.records.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
@@ -14,7 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.core.model.Record
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
import org.koin.compose.viewmodel.koinViewModel
@@ -2,8 +2,8 @@ package fr.ajaury.gwenedeg.records.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.records.domain.RecordRepository
import fr.ajaury.gwenedeg.core.model.Record
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn