new: allow scrolling on subtitle list and advance playback
This commit is contained in:
@@ -8,7 +8,7 @@ import fr.ajaury.gwenedeg.records.domain.RecordRepository
|
|||||||
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
|
import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel
|
||||||
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
|
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
|
||||||
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository
|
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
|
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
||||||
import org.koin.core.module.dsl.bind
|
import org.koin.core.module.dsl.bind
|
||||||
import org.koin.core.module.dsl.factoryOf
|
import org.koin.core.module.dsl.factoryOf
|
||||||
@@ -20,7 +20,7 @@ val sharedModule = module {
|
|||||||
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
|
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
|
||||||
factoryOf(::LrcParser)
|
factoryOf(::LrcParser)
|
||||||
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
|
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
|
||||||
factoryOf(::GetSubtitleWindowUseCase)
|
factoryOf(::GetCurrentSubtitleIndexUseCase)
|
||||||
viewModelOf(::RecordsViewModel)
|
viewModelOf(::RecordsViewModel)
|
||||||
viewModelOf(::PlayerViewModel)
|
viewModelOf(::PlayerViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
package fr.ajaury.gwenedeg.player.ui
|
package fr.ajaury.gwenedeg.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
@@ -25,15 +30,17 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
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.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
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
|
||||||
@@ -41,16 +48,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import fr.ajaury.gwenedeg.player.PlaybackState
|
import fr.ajaury.gwenedeg.player.PlaybackState
|
||||||
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
|
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
|
||||||
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
|
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
||||||
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
recordTitle: String,
|
recordTitle: String,
|
||||||
audioResourcePath: String,
|
audioResourcePath: String,
|
||||||
subtitleResourcePath: String,
|
subtitleResourcePath: String,
|
||||||
onBackClicked: () -> Unit,
|
onBackClicked: () -> Unit = {},
|
||||||
viewModel: PlayerViewModel = koinViewModel(),
|
viewModel: PlayerViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -67,7 +75,8 @@ fun PlayerScreen(
|
|||||||
recordTitle = recordTitle,
|
recordTitle = recordTitle,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
|
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
|
||||||
onSeek = viewModel::seekTo,
|
onSeekToTimePart = viewModel::seekToTimePart,
|
||||||
|
onSeekToSentence = viewModel::seekToSentence,
|
||||||
onBackClicked = onBackClicked,
|
onBackClicked = onBackClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,9 +86,10 @@ fun PlayerScreen(
|
|||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
recordTitle: String,
|
recordTitle: String,
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
onMainPlayActionButtonClicked: () -> Unit,
|
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||||
onSeek: (progress: Float) -> Unit,
|
onSeekToTimePart: (progress: Float) -> Unit = {},
|
||||||
onBackClicked: () -> Unit,
|
onSeekToSentence: (position: Int) -> Unit = {},
|
||||||
|
onBackClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -101,69 +111,126 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
contentAlignment = Alignment.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Column(
|
SubtitleList(
|
||||||
modifier = Modifier.fillMaxSize(),
|
lines = uiState.subtitleLines,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
currentIndex = uiState.currentSubtitleIndex,
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
onSeek = onSeekToSentence,
|
||||||
) {
|
modifier = Modifier
|
||||||
Spacer(Modifier)
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
Subtitles(
|
PlayerState(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
)
|
onSeek = onSeekToTimePart,
|
||||||
|
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PlayerState(
|
@Composable
|
||||||
uiState = uiState,
|
private fun SubtitleList(
|
||||||
onSeek = onSeek,
|
lines: List<SubtitleLine>,
|
||||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
currentIndex: Int?,
|
||||||
|
onSeek: (position: Int) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val isDragged by listState.interactionSource.collectIsDraggedAsState()
|
||||||
|
|
||||||
|
val centeredIndex by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
if (info.visibleItemsInfo.isEmpty()) {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f
|
||||||
|
info.visibleItemsInfo.minByOrNull { abs((it.offset + it.size / 2f) - viewportCenter) }!!.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-follow: re-center the playing line when it changes, unless the user is scrolling.
|
||||||
|
LaunchedEffect(currentIndex) {
|
||||||
|
if (currentIndex != null && !listState.isScrollInProgress) {
|
||||||
|
listState.centerItem(currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap to the centered line and seek to it once the user finishes scrolling.
|
||||||
|
LaunchedEffect(listState, lines) {
|
||||||
|
var wasDragged = false
|
||||||
|
snapshotFlow { isDragged to listState.isScrollInProgress }
|
||||||
|
.collect { (dragged, scrolling) ->
|
||||||
|
if (dragged) {
|
||||||
|
wasDragged = true
|
||||||
|
} else if (wasDragged && !scrolling) {
|
||||||
|
wasDragged = false
|
||||||
|
val targetIndex = centeredIndex
|
||||||
|
if (targetIndex in lines.indices) {
|
||||||
|
listState.centerItem(targetIndex)
|
||||||
|
onSeek(targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
// Half-height padding so the first and last lines can reach the vertical center.
|
||||||
|
contentPadding = PaddingValues(vertical = maxHeight / 2),
|
||||||
|
) {
|
||||||
|
itemsIndexed(lines) { index, line ->
|
||||||
|
val isCurrent = index == centeredIndex
|
||||||
|
Subtitle(
|
||||||
|
line = line,
|
||||||
|
isCurrent = isCurrent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private suspend fun LazyListState.centerItem(index: Int) {
|
||||||
private fun Subtitles(uiState: PlayerUiState) {
|
val info = this.layoutInfo
|
||||||
Column(
|
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val item = info.visibleItemsInfo.firstOrNull { it.index == index }
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
if (item != null) {
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
this.animateScrollBy((item.offset + item.size / 2 - viewportCenter).toFloat())
|
||||||
) {
|
} else {
|
||||||
Subtitle(
|
this.animateScrollToItem(index)
|
||||||
text = uiState.subtitles.previous,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
Subtitle(
|
|
||||||
text = uiState.subtitles.current,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = Color.Unspecified,
|
|
||||||
)
|
|
||||||
Subtitle(
|
|
||||||
text = uiState.subtitles.next,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Subtitle(
|
private fun Subtitle(
|
||||||
text: String,
|
line: SubtitleLine,
|
||||||
style: TextStyle,
|
isCurrent: Boolean,
|
||||||
color: Color,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = line.text,
|
||||||
style = style,
|
style = if (isCurrent) {
|
||||||
color = color,
|
MaterialTheme.typography.headlineSmall
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.bodyLarge
|
||||||
|
},
|
||||||
|
color = if (isCurrent) {
|
||||||
|
Color.Unspecified
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
},
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -222,6 +289,13 @@ private fun PlaybackState.toActionContentDescription(): String =
|
|||||||
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
|
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val previewSubtitleLines =
|
||||||
|
listOf(
|
||||||
|
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
|
||||||
|
SubtitleLine(startMs = 4310, text = "Kenavo !"),
|
||||||
|
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
|
||||||
|
)
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayerScreenPlayingPreview() {
|
private fun PlayerScreenPlayingPreview() {
|
||||||
@@ -232,14 +306,11 @@ private fun PlayerScreenPlayingPreview() {
|
|||||||
playbackState = PlaybackState.PLAYING,
|
playbackState = PlaybackState.PLAYING,
|
||||||
positionMs = 1234,
|
positionMs = 1234,
|
||||||
durationMs = 5000,
|
durationMs = 5000,
|
||||||
subtitles = SubtitleWindow(
|
subtitleLines = previewSubtitleLines,
|
||||||
previous = "Demat deoc'h !",
|
currentSubtitleIndex = 1,
|
||||||
current = "Kenavo !",
|
|
||||||
next = "Kenavo emberr !",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeekToSentence = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -255,14 +326,11 @@ private fun PlayerScreenPausedPreview() {
|
|||||||
playbackState = PlaybackState.PAUSED,
|
playbackState = PlaybackState.PAUSED,
|
||||||
positionMs = 1234,
|
positionMs = 1234,
|
||||||
durationMs = 5000,
|
durationMs = 5000,
|
||||||
subtitles = SubtitleWindow(
|
subtitleLines = previewSubtitleLines,
|
||||||
previous = "Demat deoc'h !",
|
currentSubtitleIndex = 1,
|
||||||
current = "Kenavo !",
|
|
||||||
next = "Kenavo emberr !",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeekToSentence = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,14 +346,11 @@ private fun PlayerScreenEndedPreview() {
|
|||||||
playbackState = PlaybackState.ENDED,
|
playbackState = PlaybackState.ENDED,
|
||||||
positionMs = 1234,
|
positionMs = 1234,
|
||||||
durationMs = 5000,
|
durationMs = 5000,
|
||||||
subtitles = SubtitleWindow(
|
subtitleLines = previewSubtitleLines,
|
||||||
previous = "Demat deoc'h !",
|
currentSubtitleIndex = 2,
|
||||||
current = "Kenavo !",
|
|
||||||
next = "Kenavo emberr !",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeekToSentence = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,13 +1,14 @@
|
|||||||
package fr.ajaury.gwenedeg.player.ui.viewmodel
|
package fr.ajaury.gwenedeg.player.ui.viewmodel
|
||||||
|
|
||||||
import fr.ajaury.gwenedeg.player.PlaybackState
|
import fr.ajaury.gwenedeg.player.PlaybackState
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
|
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
val playbackState: PlaybackState = PlaybackState.IDLE,
|
val playbackState: PlaybackState = PlaybackState.IDLE,
|
||||||
val positionMs: Long = 0L,
|
val positionMs: Long = 0L,
|
||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val subtitles: SubtitleWindow = SubtitleWindow(),
|
val subtitleLines: List<SubtitleLine> = emptyList(),
|
||||||
|
val currentSubtitleIndex: Int? = null,
|
||||||
) {
|
) {
|
||||||
val progress: Float
|
val progress: Float
|
||||||
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
|
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
|
||||||
|
|||||||
+10
-4
@@ -6,7 +6,7 @@ import fr.ajaury.gwenedeg.core.logging.Logger
|
|||||||
import fr.ajaury.gwenedeg.player.AudioPlayer
|
import fr.ajaury.gwenedeg.player.AudioPlayer
|
||||||
import fr.ajaury.gwenedeg.player.AudioSessionManager
|
import fr.ajaury.gwenedeg.player.AudioSessionManager
|
||||||
import fr.ajaury.gwenedeg.player.PlaybackState
|
import fr.ajaury.gwenedeg.player.PlaybackState
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
|
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
||||||
import gwenedeg.shared.generated.resources.Res
|
import gwenedeg.shared.generated.resources.Res
|
||||||
@@ -29,7 +29,7 @@ class PlayerViewModel(
|
|||||||
private val audioPlayer: AudioPlayer,
|
private val audioPlayer: AudioPlayer,
|
||||||
private val audioSessionManager: AudioSessionManager,
|
private val audioSessionManager: AudioSessionManager,
|
||||||
private val subtitleRepository: SubtitleRepository,
|
private val subtitleRepository: SubtitleRepository,
|
||||||
private val getSubtitleWindow: GetSubtitleWindowUseCase,
|
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var filePath: String? = null
|
private var filePath: String? = null
|
||||||
@@ -62,7 +62,8 @@ class PlayerViewModel(
|
|||||||
playbackState = playbackState,
|
playbackState = playbackState,
|
||||||
positionMs = currentPosition.inWholeMilliseconds,
|
positionMs = currentPosition.inWholeMilliseconds,
|
||||||
durationMs = audioPlayer.duration.inWholeMilliseconds,
|
durationMs = audioPlayer.duration.inWholeMilliseconds,
|
||||||
subtitles = getSubtitleWindow(
|
subtitleLines = subtitle.lines,
|
||||||
|
currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
progressMs = currentPosition.inWholeMilliseconds,
|
progressMs = currentPosition.inWholeMilliseconds,
|
||||||
),
|
),
|
||||||
@@ -110,11 +111,16 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(progress: Float) {
|
fun seekToTimePart(progress: Float) {
|
||||||
val position = (progress.toDouble() * uiState.value.durationMs).milliseconds
|
val position = (progress.toDouble() * uiState.value.durationMs).milliseconds
|
||||||
audioPlayer.seekTo(position)
|
audioPlayer.seekTo(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun seekToSentence(sentenceIndex: Int) {
|
||||||
|
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
||||||
|
audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
audioPlayer.stop()
|
audioPlayer.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package fr.ajaury.gwenedeg.subtitle.domain
|
||||||
|
|
||||||
|
class GetCurrentSubtitleIndexUseCase {
|
||||||
|
operator fun invoke(
|
||||||
|
subtitle: Subtitle,
|
||||||
|
progressMs: Long,
|
||||||
|
): Int = subtitle.lines.indexOfLast { it.startMs <= progressMs }
|
||||||
|
}
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
package fr.ajaury.gwenedeg.subtitle.domain
|
|
||||||
|
|
||||||
class GetSubtitleWindowUseCase {
|
|
||||||
operator fun invoke(
|
|
||||||
subtitle: Subtitle,
|
|
||||||
progressMs: Long,
|
|
||||||
): SubtitleWindow {
|
|
||||||
val lines = subtitle.lines
|
|
||||||
val currentIndex = lines.indexOfLast { it.startMs <= progressMs }
|
|
||||||
return SubtitleWindow(
|
|
||||||
previous = lines.getOrNull(currentIndex - 1)?.text.orEmpty(),
|
|
||||||
current = lines.getOrNull(currentIndex)?.text.orEmpty(),
|
|
||||||
next = lines.getOrNull(currentIndex + 1)?.text.orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package fr.ajaury.gwenedeg.subtitle.domain
|
|
||||||
|
|
||||||
data class SubtitleWindow(
|
|
||||||
val previous: String = "",
|
|
||||||
val current: String = "",
|
|
||||||
val next: String = "",
|
|
||||||
)
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
package fr.ajaury.gwenedeg.subtitle
|
|
||||||
|
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleWindowUseCase
|
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
|
|
||||||
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleWindow
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class GetSubtitleWindowUseCaseTest {
|
|
||||||
private val getSubtitleWindow = GetSubtitleWindowUseCase()
|
|
||||||
|
|
||||||
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 before_the_first_cue_only_next_is_set() {
|
|
||||||
assertEquals(
|
|
||||||
SubtitleWindow(previous = "", current = "", next = "Demat deoc'h !"),
|
|
||||||
getSubtitleWindow(subtitle, progressMs = -1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun on_the_first_cue_there_is_no_previous() {
|
|
||||||
assertEquals(
|
|
||||||
SubtitleWindow(previous = "", current = "Demat deoc'h !", next = "Kenavo !"),
|
|
||||||
getSubtitleWindow(subtitle, progressMs = 0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun on_a_middle_cue_all_three_are_set() {
|
|
||||||
assertEquals(
|
|
||||||
SubtitleWindow(previous = "Demat deoc'h !", current = "Kenavo !", next = "Kenavo emberr !"),
|
|
||||||
getSubtitleWindow(subtitle, progressMs = 5000),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun on_the_last_cue_there_is_no_next() {
|
|
||||||
assertEquals(
|
|
||||||
SubtitleWindow(previous = "Kenavo !", current = "Kenavo emberr !", next = ""),
|
|
||||||
getSubtitleWindow(subtitle, progressMs = 50_000),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun an_empty_subtitle_yields_an_empty_window() {
|
|
||||||
assertEquals(
|
|
||||||
SubtitleWindow(),
|
|
||||||
getSubtitleWindow(Subtitle(emptyList()), progressMs = 1_000),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user