refactor: move subtitle package to standalone data module, update dependencies
This commit is contained in:
@@ -62,6 +62,9 @@ kotlin {
|
||||
implementation(projects.core.audioplayer)
|
||||
implementation(projects.core.logging)
|
||||
|
||||
// Data modules
|
||||
implementation(projects.data.subtitle)
|
||||
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
|
||||
@@ -13,6 +13,7 @@ import fr.ajaury.gwenedeg.navigation.Route
|
||||
import fr.ajaury.gwenedeg.player.di.audioPlayerModule
|
||||
import fr.ajaury.gwenedeg.player.ui.PlayerScreen
|
||||
import fr.ajaury.gwenedeg.records.ui.RecordsScreen
|
||||
import fr.ajaury.gwenedeg.subtitle.di.subtitleModule
|
||||
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||
import org.koin.compose.KoinApplication
|
||||
import org.koin.dsl.koinConfiguration
|
||||
@@ -25,7 +26,8 @@ fun App() {
|
||||
modules(
|
||||
sharedModule,
|
||||
audioPlayerModule,
|
||||
loggingModule
|
||||
loggingModule,
|
||||
subtitleModule
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -4,10 +4,8 @@ import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
||||
import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository
|
||||
import fr.ajaury.gwenedeg.records.domain.RecordRepository
|
||||
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
|
||||
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
|
||||
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
||||
import fr.ajaury.gwenedeg.resourcereader.data.ComposeResourceReader
|
||||
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
@@ -15,9 +13,7 @@ import org.koin.dsl.module
|
||||
|
||||
val sharedModule = module {
|
||||
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
|
||||
factoryOf(::LrcParser)
|
||||
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
|
||||
factoryOf(::GetCurrentSubtitleIndexUseCase)
|
||||
viewModelOf(::RecordsViewModel)
|
||||
viewModelOf(::PlayerViewModel)
|
||||
factoryOf(::ComposeResourceReader) { bind<ResourceReader>() }
|
||||
}
|
||||
|
||||
+4
-1
@@ -6,6 +6,7 @@ import fr.ajaury.gwenedeg.core.logging.domain.Logger
|
||||
import fr.ajaury.gwenedeg.player.domain.AudioPlayer
|
||||
import fr.ajaury.gwenedeg.player.domain.AudioSessionManager
|
||||
import fr.ajaury.gwenedeg.player.model.PlaybackState
|
||||
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
|
||||
@@ -26,6 +27,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class PlayerViewModel(
|
||||
private val resourceReader: ResourceReader,
|
||||
private val audioPlayer: AudioPlayer,
|
||||
private val audioSessionManager: AudioSessionManager,
|
||||
private val subtitleRepository: SubtitleRepository,
|
||||
@@ -92,7 +94,8 @@ class PlayerViewModel(
|
||||
viewModelScope.launch {
|
||||
subtitle.value =
|
||||
try {
|
||||
subtitleRepository.getSubtitle(resourcePath)
|
||||
val content = resourceReader.read(resourcePath = resourcePath)
|
||||
subtitleRepository.getSubtitle(content = content)
|
||||
} catch (exception: Exception) {
|
||||
logger.error(
|
||||
message = "Failed to load subtitle: $resourcePath",
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package fr.ajaury.gwenedeg.resourcereader.data
|
||||
|
||||
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
|
||||
import gwenedeg.shared.generated.resources.Res
|
||||
|
||||
internal class ComposeResourceReader : ResourceReader {
|
||||
override suspend fun read(resourcePath: String): String = Res.readBytes(resourcePath).decodeToString()
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package fr.ajaury.gwenedeg.resourcereader.data.domain
|
||||
|
||||
/**
|
||||
* Reads the raw text of a subtitle file. Implemented by the host app, which owns the resource
|
||||
* bundle, so this module stays free of any platform/resource dependency.
|
||||
*/
|
||||
interface ResourceReader {
|
||||
suspend fun read(resourcePath: String): String
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.data
|
||||
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
||||
|
||||
/**
|
||||
* Parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) subtitle content.
|
||||
*
|
||||
* Only numeric timestamp tags `[mm:ss.xx]` / `[mm:ss.xxx]` are kept. A line may carry several
|
||||
* leading timestamps that all share the same text.
|
||||
*/
|
||||
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
|
||||
.lineSequence()
|
||||
.flatMap { line -> parseLine(line) }
|
||||
.sortedBy { it.startMs }
|
||||
.toList()
|
||||
return Subtitle(lines)
|
||||
}
|
||||
|
||||
private fun parseLine(line: String): List<SubtitleLine> {
|
||||
val tags = leadingTimeTags.find(line) ?: return emptyList()
|
||||
val text = line.substring(tags.range.last + 1).trim()
|
||||
return timeTag
|
||||
.findAll(tags.value)
|
||||
.mapNotNull { match ->
|
||||
SubtitleLine(startMs = match.toMs(), text = text)
|
||||
.takeIf { it.text.isNotBlank() }
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun MatchResult.toMs(): Long {
|
||||
val (minutes, seconds, fraction) = destructured
|
||||
val fractionMs = if (fraction.length == 2) fraction.toLong() * 10 else fraction.toLong()
|
||||
return minutes.toLong() * 60_000 + seconds.toLong() * 1_000 + fractionMs
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.data
|
||||
|
||||
import fr.ajaury.gwenedeg.core.logging.domain.Logger
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
||||
import fr.ajaury.gwenedeg.subtitle.model.CantGetSubtitleException
|
||||
import gwenedeg.shared.generated.resources.Res
|
||||
|
||||
class LrcSubtitleRepository(
|
||||
private val parser: LrcParser,
|
||||
private val logger: Logger,
|
||||
) : SubtitleRepository {
|
||||
override suspend fun getSubtitle(resourcePath: String): Subtitle =
|
||||
try {
|
||||
parser.parse(Res.readBytes(resourcePath).decodeToString())
|
||||
} catch (exception: Exception) {
|
||||
val message = "Failed to load subtitle: $resourcePath"
|
||||
logger.error(message = message, throwable = exception)
|
||||
throw CantGetSubtitleException(message)
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.domain
|
||||
|
||||
class GetCurrentSubtitleIndexUseCase {
|
||||
operator fun invoke(
|
||||
subtitle: Subtitle,
|
||||
progressMs: Long,
|
||||
): Int = subtitle.lines.indexOfLast { it.startMs <= progressMs }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.domain
|
||||
|
||||
data class Subtitle(
|
||||
val lines: List<SubtitleLine>,
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.domain
|
||||
|
||||
/** A single timed subtitle cue: [text] becomes active at [startMs]. */
|
||||
data class SubtitleLine(
|
||||
val startMs: Long,
|
||||
val text: String,
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.domain
|
||||
|
||||
interface SubtitleRepository {
|
||||
suspend fun getSubtitle(resourcePath: String): Subtitle
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.model
|
||||
|
||||
data class CantGetSubtitleException(
|
||||
override val message: String,
|
||||
) : Exception()
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle
|
||||
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class GetCurrentSubtitleIndexUseCaseTest {
|
||||
private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase()
|
||||
|
||||
private val subtitle =
|
||||
Subtitle(
|
||||
lines =
|
||||
listOf(
|
||||
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
|
||||
SubtitleLine(startMs = 4310, text = "Kenavo !"),
|
||||
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun returns_minus_one_before_the_first_cue() {
|
||||
assertEquals(-1, getCurrentSubtitleIndex(subtitle, progressMs = -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_the_index_at_and_after_each_cue_start() {
|
||||
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 0))
|
||||
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 4309))
|
||||
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 4310))
|
||||
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 5000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_the_last_index_past_the_end() {
|
||||
assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_minus_one_for_an_empty_subtitle() {
|
||||
assertEquals(-1, getCurrentSubtitleIndex(Subtitle(emptyList()), progressMs = 1_000))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package fr.ajaury.gwenedeg.subtitle
|
||||
|
||||
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class LrcParserTest {
|
||||
private val parser = LrcParser()
|
||||
|
||||
private val sample =
|
||||
"""
|
||||
[ti:Chom_bev_01_ss]
|
||||
[re: Subtitle Edit]
|
||||
[ve: 5.0.0.4]
|
||||
|
||||
[00:00.00]Demat deoc'h !
|
||||
[00:04.31]Kenavo !
|
||||
[00:05.40]
|
||||
[00:07.35]Kenavo emberr !
|
||||
[00:44.14]
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun ignores_metadata_tags_and_keeps_only_timed_cues() {
|
||||
val subtitle = parser.parse(sample)
|
||||
|
||||
assertEquals(3, subtitle.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun converts_timestamp_to_milliseconds() {
|
||||
val subtitle = parser.parse(sample)
|
||||
|
||||
assertEquals(0, subtitle.lines[0].startMs)
|
||||
assertEquals(4310, subtitle.lines[1].startMs)
|
||||
assertEquals(7350, subtitle.lines[2].startMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parses_three_digit_fraction_as_milliseconds() {
|
||||
val subtitle = parser.parse("[00:01.250]Hello")
|
||||
|
||||
assertEquals(1250, subtitle.lines.single().startMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sorts_cues_ascending_by_start() {
|
||||
val subtitle = parser.parse("[00:05.00]Later\n[00:01.00]Earlier")
|
||||
|
||||
assertEquals(listOf("Earlier", "Later"), subtitle.lines.map { it.text })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user