new: integrate subtitles into playback, update models, UI, and navigation
This commit is contained in:
@@ -45,6 +45,7 @@ fun App() {
|
|||||||
Route.Player(
|
Route.Player(
|
||||||
title = record.title,
|
title = record.title,
|
||||||
audioResourcePath = record.audioResourcePath,
|
audioResourcePath = record.audioResourcePath,
|
||||||
|
subtitleResourcePath = record.subtitleResourcePath,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -55,6 +56,7 @@ fun App() {
|
|||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = key.title,
|
recordTitle = key.title,
|
||||||
audioResourcePath = key.audioResourcePath,
|
audioResourcePath = key.audioResourcePath,
|
||||||
|
subtitleResourcePath = key.subtitleResourcePath,
|
||||||
onBackClicked = { backStack.removeLastOrNull() },
|
onBackClicked = { backStack.removeLastOrNull() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ sealed interface Route : NavKey {
|
|||||||
data class Player(
|
data class Player(
|
||||||
val title: String,
|
val title: String,
|
||||||
val audioResourcePath: String,
|
val audioResourcePath: String,
|
||||||
|
val subtitleResourcePath: String,
|
||||||
) : Route
|
) : Route
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
|
|||||||
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.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
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
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -45,13 +46,15 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
recordTitle: String,
|
recordTitle: String,
|
||||||
audioResourcePath: String,
|
audioResourcePath: 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()
|
||||||
|
|
||||||
DisposableEffect(audioResourcePath) {
|
DisposableEffect(audioResourcePath, subtitleResourcePath) {
|
||||||
viewModel.play(filePath = audioResourcePath)
|
viewModel.play(filePath = audioResourcePath)
|
||||||
|
viewModel.loadSubtitle(resourcePath = subtitleResourcePath)
|
||||||
onDispose {
|
onDispose {
|
||||||
viewModel.stop()
|
viewModel.stop()
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,13 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = uiState.subtitle,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
var seekProgress by remember { mutableStateOf<Float?>(null) }
|
var seekProgress by remember { mutableStateOf<Float?>(null) }
|
||||||
Slider(
|
Slider(
|
||||||
value = seekProgress ?: uiState.progress,
|
value = seekProgress ?: uiState.progress,
|
||||||
@@ -158,7 +168,7 @@ private fun PlayerScreenPlayingPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000),
|
uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeek = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
@@ -172,7 +182,7 @@ private fun PlayerScreenPausedPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000),
|
uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeek = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
@@ -186,7 +196,7 @@ private fun PlayerScreenEndedPreview() {
|
|||||||
GwenedegTheme {
|
GwenedegTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
recordTitle = "Demat",
|
recordTitle = "Demat",
|
||||||
uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000),
|
uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"),
|
||||||
onMainPlayActionButtonClicked = {},
|
onMainPlayActionButtonClicked = {},
|
||||||
onSeek = {},
|
onSeek = {},
|
||||||
onBackClicked = {},
|
onBackClicked = {},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 subtitle: String = "",
|
||||||
) {
|
) {
|
||||||
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
|
||||||
|
|||||||
+25
-2
@@ -6,9 +6,13 @@ 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.GetSubtitleTextUseCase
|
||||||
|
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
|
||||||
|
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
|
||||||
import gwenedeg.shared.generated.resources.Res
|
import gwenedeg.shared.generated.resources.Res
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.WhileSubscribed
|
import kotlinx.coroutines.flow.WhileSubscribed
|
||||||
@@ -17,18 +21,23 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class PlayerViewModel(
|
class PlayerViewModel(
|
||||||
private val audioPlayer: AudioPlayer,
|
private val audioPlayer: AudioPlayer,
|
||||||
private val audioSessionManager: AudioSessionManager,
|
private val audioSessionManager: AudioSessionManager,
|
||||||
|
private val subtitleRepository: SubtitleRepository,
|
||||||
|
private val getSubtitleText: GetSubtitleTextUseCase,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var filePath: String? = null
|
private var filePath: String? = null
|
||||||
|
|
||||||
private val playbackState = audioPlayer.playbackState
|
private val playbackState = audioPlayer.playbackState
|
||||||
|
|
||||||
|
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private val currentPosition = playbackState
|
private val currentPosition = playbackState
|
||||||
.flatMapLatest { state ->
|
.flatMapLatest { state ->
|
||||||
@@ -47,11 +56,13 @@ class PlayerViewModel(
|
|||||||
val uiState: StateFlow<PlayerUiState> = combine(
|
val uiState: StateFlow<PlayerUiState> = combine(
|
||||||
playbackState,
|
playbackState,
|
||||||
currentPosition,
|
currentPosition,
|
||||||
) { playbackState, currentPositionMs ->
|
subtitle,
|
||||||
|
) { playbackState, currentPosition, subtitle ->
|
||||||
PlayerUiState(
|
PlayerUiState(
|
||||||
playbackState = playbackState,
|
playbackState = playbackState,
|
||||||
positionMs = currentPositionMs.inWholeMilliseconds,
|
positionMs = currentPosition.inWholeMilliseconds,
|
||||||
durationMs = audioPlayer.duration.inWholeMilliseconds,
|
durationMs = audioPlayer.duration.inWholeMilliseconds,
|
||||||
|
subtitle = getSubtitleText(subtitle, currentPosition.inWholeMilliseconds),
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -73,6 +84,18 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadSubtitle(resourcePath: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
subtitle.value =
|
||||||
|
try {
|
||||||
|
subtitleRepository.getSubtitle(resourcePath)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
logger.error(message = "Failed to load subtitle: $resourcePath", throwable = exception)
|
||||||
|
Subtitle(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun performMainPlayAction() {
|
fun performMainPlayAction() {
|
||||||
when (uiState.value.playbackState) {
|
when (uiState.value.playbackState) {
|
||||||
PlaybackState.PLAYING -> audioPlayer.pause()
|
PlaybackState.PLAYING -> audioPlayer.pause()
|
||||||
|
|||||||
+3
-1
@@ -33,10 +33,12 @@ class InMemoryRecordRepository : RecordRepository {
|
|||||||
"Lec'hiiñ",
|
"Lec'hiiñ",
|
||||||
).mapIndexed { index, title ->
|
).mapIndexed { index, title ->
|
||||||
val number = index + 1
|
val number = index + 1
|
||||||
|
val paddedNumber = number.toString().padStart(2, '0')
|
||||||
Record(
|
Record(
|
||||||
index = number,
|
index = number,
|
||||||
title = title,
|
title = title,
|
||||||
audioResourcePath = "files/Chom_bev_${number.toString().padStart(2, '0')}.m4a",
|
audioResourcePath = "files/Chom_bev_$paddedNumber.m4a",
|
||||||
|
subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ data class Record(
|
|||||||
val index: Int,
|
val index: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val audioResourcePath: String,
|
val audioResourcePath: String,
|
||||||
|
val subtitleResourcePath: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ fun RecordView(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RecordViewPreview() {
|
private fun RecordViewPreview() {
|
||||||
RecordView(
|
RecordView(
|
||||||
record = Record(index = 1, title = "Sample Record", audioResourcePath = "files/Chom_bev_01.ogg"),
|
record =
|
||||||
|
Record(
|
||||||
|
index = 1,
|
||||||
|
title = "Sample Record",
|
||||||
|
audioResourcePath = "files/Chom_bev_01.ogg",
|
||||||
|
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
|
||||||
|
),
|
||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user