new: extract Records feature module
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("gwenedeg.kmp.library")
|
||||
id("gwenedeg.compose")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "bzh.ajaury.chombev.records"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
// Core modules
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.logging)
|
||||
implementation(projects.core.audioplayer)
|
||||
|
||||
// Data modules
|
||||
implementation(projects.data.playback)
|
||||
implementation(projects.data.preferences)
|
||||
implementation(projects.data.records)
|
||||
implementation(projects.data.subtitle)
|
||||
implementation(projects.data.resources)
|
||||
|
||||
// UI - Compose
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons.extended)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.compose.components.resources)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.lifecycle.viewmodelCompose)
|
||||
implementation(libs.lifecycle.runtimeCompose)
|
||||
|
||||
// DI
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.core.viewmodel)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidRuntimeClasspath(libs.compose.uiTooling)
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package bzh.ajaury.chombev.player.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.player.ui.components.LabeledSlider
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title
|
||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value
|
||||
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent
|
||||
* pause inserted between phrases.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaybackSpeedBottomSheet(
|
||||
speed: Float,
|
||||
breakSeconds: Float,
|
||||
onSpeedChange: (Float) -> Unit = {},
|
||||
onBreakChange: (Float) -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
PlaybackSpeedContent(
|
||||
speed = speed,
|
||||
breakSeconds = breakSeconds,
|
||||
onSpeedChange = onSpeedChange,
|
||||
onBreakChange = onBreakChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackSpeedContent(
|
||||
speed: Float,
|
||||
breakSeconds: Float,
|
||||
onSpeedChange: (Float) -> Unit = {},
|
||||
onBreakChange: (Float) -> Unit = {},
|
||||
) {
|
||||
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
|
||||
val speedSteps = with(PlaybackPreferences) {
|
||||
(((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0)
|
||||
}
|
||||
val maxBreakSeconds = PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds
|
||||
val breakStepSeconds = PlaybackPreferences.BREAK_STEP.inWholeSeconds
|
||||
val breakSteps = ((maxBreakSeconds / breakStepSeconds).toInt() - 1).coerceAtLeast(0)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LabeledSlider(
|
||||
title = stringResource(Res.string.playback_speed_title),
|
||||
valueLabel = formatPlaybackSpeed(speed),
|
||||
value = speed,
|
||||
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
|
||||
steps = speedSteps,
|
||||
onValueChange = onSpeedChange,
|
||||
)
|
||||
LabeledSlider(
|
||||
title = stringResource(Res.string.break_between_phrases_title),
|
||||
valueLabel = stringResource(
|
||||
Res.string.break_between_phrases_value,
|
||||
breakSeconds.roundToInt().toString(),
|
||||
),
|
||||
value = breakSeconds,
|
||||
valueRange = 0f..maxBreakSeconds.toFloat(),
|
||||
steps = breakSteps,
|
||||
onValueChange = onBreakChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a speed multiplier as a short label, e.g. 1.0 → "x1.0", 0.7 → "x0.7". */
|
||||
internal fun formatPlaybackSpeed(speed: Float): String {
|
||||
val tenths = (speed * 10).roundToInt()
|
||||
return "x${tenths / 10}.${tenths % 10}"
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlaybackSpeedContentPreview() {
|
||||
MaterialTheme {
|
||||
PlaybackSpeedContent(
|
||||
speed = 0.7f,
|
||||
breakSeconds = 2f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package bzh.ajaury.chombev.player.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import bzh.ajaury.chombev.player.ui.components.NextButton
|
||||
import bzh.ajaury.chombev.player.ui.components.PlayButton
|
||||
import bzh.ajaury.chombev.player.ui.components.PlaybackProgress
|
||||
import bzh.ajaury.chombev.player.ui.components.PreviousButton
|
||||
import bzh.ajaury.chombev.player.ui.components.SpeedPreferenceButton
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlayerControl(
|
||||
modifier: Modifier = Modifier,
|
||||
playbackState: PlaybackState,
|
||||
playbackTiming: PlaybackTiming,
|
||||
breakBetweenPhrases: Duration,
|
||||
canGoToPreviousSentence: Boolean,
|
||||
canGoToNextSentence: Boolean,
|
||||
isPlaybackModified: Boolean,
|
||||
onSeek: (Duration) -> Unit = {},
|
||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||
onPreviousSentenceClicked: () -> Unit = {},
|
||||
onNextSentenceClicked: () -> Unit = {},
|
||||
onSpeedClicked: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PlaybackProgress(
|
||||
playbackTiming = playbackTiming,
|
||||
onSeek = onSeek,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Left region: the speed button is centered in the space left of the transport group.
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SpeedPreferenceButton(
|
||||
isActive = isPlaybackModified,
|
||||
onSpeedClicked = onSpeedClicked,
|
||||
)
|
||||
}
|
||||
|
||||
// Transport group: centered in the full width by the equal-weight cells on either side.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PreviousButton(
|
||||
canGoToPreviousSentence = canGoToPreviousSentence,
|
||||
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||
)
|
||||
|
||||
PlayButton(
|
||||
playbackState = playbackState,
|
||||
breakDuration = breakBetweenPhrases,
|
||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||
)
|
||||
|
||||
NextButton(
|
||||
canGoToNextSentence = canGoToNextSentence,
|
||||
onNextSentenceClicked = onNextSentenceClicked,
|
||||
)
|
||||
}
|
||||
|
||||
// Right region: mirrors the left weight so the transport group stays centered.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerControlPreview() {
|
||||
MaterialTheme {
|
||||
PlayerControl(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
breakBetweenPhrases = 3.seconds,
|
||||
canGoToPreviousSentence = true,
|
||||
canGoToNextSentence = true,
|
||||
isPlaybackModified = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerControlFirstSentencePreview() {
|
||||
MaterialTheme {
|
||||
PlayerControl(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
breakBetweenPhrases = 3.seconds,
|
||||
canGoToPreviousSentence = false,
|
||||
canGoToNextSentence = true,
|
||||
isPlaybackModified = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerControlActiveSpeedPreview() {
|
||||
MaterialTheme {
|
||||
PlayerControl(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
breakBetweenPhrases = 3.seconds,
|
||||
canGoToPreviousSentence = true,
|
||||
canGoToNextSentence = true,
|
||||
isPlaybackModified = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerControlLastSentencePreview() {
|
||||
MaterialTheme {
|
||||
PlayerControl(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
breakBetweenPhrases = 3.seconds,
|
||||
canGoToPreviousSentence = true,
|
||||
canGoToNextSentence = false,
|
||||
isPlaybackModified = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package bzh.ajaury.chombev.player.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Subtitles
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerUiState
|
||||
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
||||
import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_back
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action
|
||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
recordId: Int,
|
||||
onBackClicked: () -> Unit = {},
|
||||
viewModel: PlayerViewModel = koinViewModel(key = "$recordId") { parametersOf(recordId) },
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.loadRecord()
|
||||
|
||||
onDispose {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
PlayerScreen(
|
||||
uiState = uiState,
|
||||
onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
|
||||
onSeekToTimePart = viewModel::seekToTimePart,
|
||||
onSeekToSentence = viewModel::seekToSentence,
|
||||
onPreviousSentenceClicked = viewModel::goToPreviousSentence,
|
||||
onNextSentenceClicked = viewModel::goToNextSentence,
|
||||
onShowTranscriptionChange = viewModel::setShowTranscription,
|
||||
onShowTranslationChange = viewModel::setShowTranslation,
|
||||
onShowPhoneticMarkerChange = viewModel::setShowPhoneticMarkers,
|
||||
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
|
||||
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
|
||||
onSpeedChange = viewModel::setPlaybackSpeed,
|
||||
onBreakChange = viewModel::setBreakBetweenPhrases,
|
||||
onBackClicked = onBackClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
uiState: PlayerUiState,
|
||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||
onSeekToTimePart: (progress: Duration) -> Unit = {},
|
||||
onSeekToSentence: (position: Int) -> Unit = {},
|
||||
onPreviousSentenceClicked: () -> Unit = {},
|
||||
onNextSentenceClicked: () -> Unit = {},
|
||||
onShowTranscriptionChange: (Boolean) -> Unit = {},
|
||||
onShowTranslationChange: (Boolean) -> Unit = {},
|
||||
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
onSpeedChange: (Float) -> Unit = {},
|
||||
onBreakChange: (Float) -> Unit = {},
|
||||
onBackClicked: () -> Unit = {},
|
||||
) {
|
||||
var showPreferences by remember { mutableStateOf(false) }
|
||||
var showSpeed by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.recordEmoji,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = uiState.recordTitle.transcription,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
uiState.recordTitle.translation?.let { translation ->
|
||||
Text(
|
||||
text = translation,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.player_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showPreferences = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Subtitles,
|
||||
contentDescription = stringResource(Res.string.subtitle_preferences_action),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 16.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
SubtitleList(
|
||||
lines = uiState.playerState.subtitleLines,
|
||||
currentIndex = uiState.playerState.currentSubtitleIndex,
|
||||
subtitlePreferences = uiState.subtitlePreferences,
|
||||
onSeek = onSeekToSentence,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
PlayerControl(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
playbackState = uiState.playerState.playbackState,
|
||||
playbackTiming = uiState.playerState.playbackTiming,
|
||||
breakBetweenPhrases = uiState.breakBetweenPhrases,
|
||||
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
|
||||
canGoToNextSentence = uiState.canGoToNextSentence,
|
||||
isPlaybackModified = uiState.isPlaybackModified,
|
||||
onSeek = onSeekToTimePart,
|
||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||
onNextSentenceClicked = onNextSentenceClicked,
|
||||
onSpeedClicked = { showSpeed = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (showPreferences) {
|
||||
SubtitlePreferencesBottomSheet(
|
||||
subtitlePreferences = uiState.subtitlePreferences,
|
||||
onShowTranscriptionChange = onShowTranscriptionChange,
|
||||
onShowTranslationChange = onShowTranslationChange,
|
||||
onShowPhoneticMarkerChange = onShowPhoneticMarkerChange,
|
||||
onTextSizeDecrease = onTextSizeDecrease,
|
||||
onTextSizeIncrease = onTextSizeIncrease,
|
||||
onDismiss = { showPreferences = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showSpeed) {
|
||||
PlaybackSpeedBottomSheet(
|
||||
speed = uiState.playbackSpeed,
|
||||
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
|
||||
onSpeedChange = onSpeedChange,
|
||||
onBreakChange = onBreakChange,
|
||||
onDismiss = { showSpeed = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val previewSubtitleLines =
|
||||
listOf(
|
||||
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
|
||||
@Composable
|
||||
private fun PlayerScreenPlayingPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
subtitleLines = previewSubtitleLines,
|
||||
currentSubtitleIndex = 1,
|
||||
),
|
||||
),
|
||||
onMainPlayActionButtonClicked = {},
|
||||
onSeekToSentence = {},
|
||||
onBackClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerScreenPausedPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.PAUSED,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
subtitleLines = previewSubtitleLines,
|
||||
currentSubtitleIndex = 1,
|
||||
),
|
||||
),
|
||||
onMainPlayActionButtonClicked = {},
|
||||
onSeekToSentence = {},
|
||||
onBackClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerScreenEndedPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.ENDED,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
subtitleLines = previewSubtitleLines,
|
||||
currentSubtitleIndex = 2,
|
||||
),
|
||||
),
|
||||
onMainPlayActionButtonClicked = {},
|
||||
onSeekToSentence = {},
|
||||
onBackClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package bzh.ajaury.chombev.player.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.player.ui.components.TranscriptionText
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun SubtitleList(
|
||||
modifier: Modifier = Modifier,
|
||||
lines: List<SubtitleLine>,
|
||||
currentIndex: Int?,
|
||||
subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||
onSeek: (position: Int) -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val isDragged by listState.interactionSource.collectIsDraggedAsState()
|
||||
var targetIndex by remember { mutableStateOf(0) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
// Detect beginning of dragging.
|
||||
LaunchedEffect(isDragged) {
|
||||
if (isDragged) {
|
||||
isDragging = true
|
||||
}
|
||||
}
|
||||
|
||||
val centeredIndex by remember(currentIndex, isDragged) {
|
||||
derivedStateOf {
|
||||
if (isDragging) {
|
||||
listState.getCenteredIndex().also {
|
||||
targetIndex = it
|
||||
}
|
||||
} else {
|
||||
currentIndex ?: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(isDragged) {
|
||||
if (isDragging && !isDragged) {
|
||||
if (targetIndex in lines.indices) {
|
||||
listState.centerItem(targetIndex)
|
||||
onSeek(targetIndex)
|
||||
}
|
||||
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
// Half-height padding so the first and last lines can reach the vertical center. Captured here
|
||||
// because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
|
||||
val verticalPadding = maxHeight / 2
|
||||
// Scale only the subtitle fonts by overriding the density's fontScale (dp layout is untouched).
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(
|
||||
density = density.density,
|
||||
fontScale = density.fontScale * subtitlePreferences.textScale,
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(vertical = verticalPadding),
|
||||
) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
val isCurrent = index == centeredIndex
|
||||
Subtitle(
|
||||
line = line,
|
||||
isCurrent = isCurrent,
|
||||
subtitlePreferences = subtitlePreferences,
|
||||
onClick = { onSeek(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListState.getCenteredIndex(): Int {
|
||||
val info = this.layoutInfo
|
||||
return if (info.visibleItemsInfo.isEmpty()) {
|
||||
-1
|
||||
} else {
|
||||
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2f
|
||||
info.visibleItemsInfo
|
||||
.minByOrNull {
|
||||
abs((it.offset + it.size / 2f) - viewportCenter)
|
||||
}?.index
|
||||
?: -1
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun LazyListState.centerItem(index: Int) {
|
||||
if (index < 0 || index >= this.layoutInfo.totalItemsCount) return
|
||||
|
||||
val info = this.layoutInfo
|
||||
val viewportCenter = (info.viewportStartOffset + info.viewportEndOffset) / 2
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
if (item != null) {
|
||||
this.animateScrollBy((item.offset + item.size / 2 - viewportCenter).toFloat())
|
||||
} else {
|
||||
this.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Subtitle(
|
||||
line: SubtitleLine,
|
||||
isCurrent: Boolean,
|
||||
subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val textAlpha = if (isCurrent) 1f else 0.35f
|
||||
val contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (subtitlePreferences.showTranscription) {
|
||||
TranscriptionLine(
|
||||
text = line.phrase.transcription,
|
||||
contentColor = contentColor,
|
||||
showPhoneticMarkers = subtitlePreferences.showPhoneticMarkers,
|
||||
)
|
||||
}
|
||||
line.phrase.translation
|
||||
?.takeIf { subtitlePreferences.showTranslation }
|
||||
?.let { translation ->
|
||||
TranslationLine(
|
||||
text = translation,
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranscriptionLine(
|
||||
text: String,
|
||||
contentColor: Color,
|
||||
showPhoneticMarkers: Boolean = true,
|
||||
) {
|
||||
TranscriptionText(
|
||||
text = text,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
showPhoneticMarkers = showPhoneticMarkers,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranslationLine(
|
||||
text: String,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
|
||||
color = contentColor,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
private val previewSubtitleLines =
|
||||
listOf(
|
||||
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 = "Tomm eo ma(n) divskouarn",
|
||||
translation = "J'ai chaud aux oreilles",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SubtitleListPreview() {
|
||||
MaterialTheme {
|
||||
SubtitleList(
|
||||
lines = previewSubtitleLines,
|
||||
currentIndex = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LabeledSlider(
|
||||
title: String,
|
||||
valueLabel: String,
|
||||
value: Float,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
steps: Int,
|
||||
onValueChange: (Float) -> Unit = {},
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = valueLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LabeledSliderPreview() {
|
||||
MaterialTheme {
|
||||
LabeledSlider(
|
||||
title = "Playback speed",
|
||||
valueLabel = "x0.7",
|
||||
value = 0.7f,
|
||||
valueRange = 0.5f..2f,
|
||||
steps = 10,
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun NextButton(
|
||||
canGoToNextSentence: Boolean,
|
||||
onNextSentenceClicked: () -> Unit = {},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(60.dp),
|
||||
enabled = canGoToNextSentence,
|
||||
onClick = onNextSentenceClicked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipNext,
|
||||
contentDescription = stringResource(Res.string.player_next_sentence),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun NextButtonCanGoToNextSentencePreview() {
|
||||
MaterialTheme {
|
||||
NextButton(
|
||||
canGoToNextSentence = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun NextButtonCanNotGoToNextSentencePreview() {
|
||||
MaterialTheme {
|
||||
NextButton(
|
||||
canGoToNextSentence = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Replay
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_pause
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_play
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_replay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val ButtonSize = 60.dp
|
||||
private val BreakRingSize = 68.dp
|
||||
|
||||
@Composable
|
||||
fun PlayButton(
|
||||
playbackState: PlaybackState,
|
||||
breakDuration: Duration = Duration.ZERO,
|
||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||
) {
|
||||
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
|
||||
val breakProgress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(isBreaking, breakDuration) {
|
||||
if (isBreaking) {
|
||||
breakProgress.snapTo(0f)
|
||||
breakProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = breakDuration.inWholeMilliseconds.toInt(),
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
breakProgress.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
// The ring reserves its footprint at all times so the transport row does not jump when a break
|
||||
// starts; the indicator itself is only drawn while breaking.
|
||||
Box(
|
||||
modifier = Modifier.size(BreakRingSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isBreaking) {
|
||||
CircularProgressIndicator(
|
||||
progress = { breakProgress.value },
|
||||
modifier = Modifier.size(BreakRingSize),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.size(ButtonSize),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
onClick = onMainPlayActionButtonClicked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = playbackState.toActionIcon(),
|
||||
contentDescription = playbackState.toActionContentDescription(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PlaybackState.toActionIcon(): ImageVector =
|
||||
when (this) {
|
||||
PlaybackState.PLAYING, PlaybackState.BREAKING -> Icons.Default.Pause
|
||||
PlaybackState.ENDED -> Icons.Default.Replay
|
||||
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackState.toActionContentDescription(): String =
|
||||
when (this) {
|
||||
PlaybackState.PLAYING, PlaybackState.BREAKING -> stringResource(Res.string.player_pause)
|
||||
PlaybackState.ENDED -> stringResource(Res.string.player_replay)
|
||||
PlaybackState.PAUSED, PlaybackState.IDLE -> stringResource(Res.string.player_play)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayButtonPlayingPreview() {
|
||||
MaterialTheme {
|
||||
PlayButton(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayButtonBreakingPreview() {
|
||||
MaterialTheme {
|
||||
PlayButton(
|
||||
playbackState = PlaybackState.BREAKING,
|
||||
breakDuration = 3.seconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayButtonPausedPreview() {
|
||||
MaterialTheme {
|
||||
PlayButton(
|
||||
playbackState = PlaybackState.PAUSED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayButtonEndedPreview() {
|
||||
MaterialTheme {
|
||||
PlayButton(
|
||||
playbackState = PlaybackState.ENDED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayButtonIdlePreview() {
|
||||
MaterialTheme {
|
||||
PlayButton(
|
||||
playbackState = PlaybackState.IDLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
fun PlaybackProgress(
|
||||
playbackTiming: PlaybackTiming,
|
||||
onSeek: (Duration) -> Unit = {},
|
||||
) {
|
||||
var seekProgress by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = seekProgress ?: playbackTiming.position.inWholeMilliseconds.toFloat(),
|
||||
valueRange = 0f..playbackTiming.duration.inWholeMilliseconds.toFloat(),
|
||||
onValueChange = { seekProgress = it },
|
||||
onValueChangeFinished = {
|
||||
seekProgress?.let { fraction ->
|
||||
if (playbackTiming.duration.isPositive()) {
|
||||
onSeek(fraction.toInt().milliseconds)
|
||||
}
|
||||
}
|
||||
seekProgress = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlaybackProgressPreview() {
|
||||
MaterialTheme {
|
||||
PlaybackProgress(
|
||||
playbackTiming = PlaybackTiming(0.seconds, 0.seconds),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlaybackProgressInProgressPreview() {
|
||||
MaterialTheme {
|
||||
PlaybackProgress(
|
||||
playbackTiming = PlaybackTiming(7.seconds, 10.seconds),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlaybackProgressEndedPreview() {
|
||||
MaterialTheme {
|
||||
PlaybackProgress(
|
||||
playbackTiming = PlaybackTiming(10.seconds, 10.seconds),
|
||||
)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PreviousButton(
|
||||
canGoToPreviousSentence: Boolean,
|
||||
onPreviousSentenceClicked: () -> Unit = {},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(60.dp),
|
||||
enabled = canGoToPreviousSentence,
|
||||
onClick = onPreviousSentenceClicked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipPrevious,
|
||||
contentDescription = stringResource(Res.string.player_previous_sentence),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviousButtonCanGoToPreviousSentencePreview() {
|
||||
MaterialTheme {
|
||||
PreviousButton(
|
||||
canGoToPreviousSentence = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviousButtonCanNotGoToPreviousSentencePreview() {
|
||||
MaterialTheme {
|
||||
PreviousButton(
|
||||
canGoToPreviousSentence = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Opens the playback settings sheet. Tinted with the primary color when a playback setting (speed or
|
||||
* break between phrases) differs from its default.
|
||||
*/
|
||||
@Composable
|
||||
fun SpeedPreferenceButton(
|
||||
isActive: Boolean,
|
||||
onSpeedClicked: () -> Unit = {},
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(60.dp),
|
||||
onClick = onSpeedClicked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Speed,
|
||||
contentDescription = stringResource(Res.string.playback_settings_action),
|
||||
tint = if (isActive) MaterialTheme.colorScheme.primary else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SpeedPreferenceButtonDefaultPreview() {
|
||||
MaterialTheme {
|
||||
SpeedPreferenceButton(
|
||||
isActive = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SpeedPreferenceButtonActivePreview() {
|
||||
MaterialTheme {
|
||||
SpeedPreferenceButton(
|
||||
isActive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package bzh.ajaury.chombev.player.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Matches a single letter marked in a transcription, either:
|
||||
* - between parentheses, e.g. the `n` in `ma(n)` (a muted consonant), captured in group 1, or
|
||||
* - between square brackets, e.g. the `t` in `[t]din` (a linking consonant), captured in
|
||||
* group 2.
|
||||
*
|
||||
* The captured group is the bare letter, without its surrounding delimiters.
|
||||
*/
|
||||
private val MARKED_LETTER_REGEX = Regex("""\{([A-Za-z])\}|\[([A-Za-z])\]""")
|
||||
|
||||
/** Diacritic drawn above a parenthesised letter to mark that it is muted. */
|
||||
private const val MUTED_MARK = "ø"
|
||||
|
||||
/**
|
||||
* Zero-width, no-break character placed on both sides of a marked letter so that line wrapping never
|
||||
* separates it from the word it is glued to — breaks still happen at spaces, exactly as for words.
|
||||
*/
|
||||
private const val WORD_JOINER = "\u2060"
|
||||
|
||||
/**
|
||||
* Renders a transcription, giving special treatment to single letters written between delimiters:
|
||||
* - `{x}` — a liaison consonant: the parentheses are dropped and the letter stays on the line, in
|
||||
* muted grey, topped with a small [MUTED_MARK].
|
||||
* - `[x]` — a linking consonant: the brackets are dropped and the letter is raised above the line,
|
||||
* in muted grey, with a small [LIAISON_MARK] underneath it.
|
||||
*
|
||||
* Marked letters are emitted as inline content so the line still wraps like normal text.
|
||||
*
|
||||
* When [showPhoneticMarkers] is `false` the pronunciation marks are dropped entirely: a muted
|
||||
* curved-bracketed letter reads as a normal letter, and a bracketed liaison letter becomes a space.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptionText(
|
||||
text: String,
|
||||
color: Color,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
textAlign: TextAlign = TextAlign.Center,
|
||||
showPhoneticMarkers: Boolean = true,
|
||||
) {
|
||||
val matches = remember(text, showPhoneticMarkers) {
|
||||
if (showPhoneticMarkers) MARKED_LETTER_REGEX.findAll(text).toList() else emptyList()
|
||||
}
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
val plainText = remember(text, showPhoneticMarkers) {
|
||||
if (showPhoneticMarkers) text else text.withoutMarkedLetters()
|
||||
}
|
||||
Text(
|
||||
text = plainText,
|
||||
color = color,
|
||||
style = style,
|
||||
textAlign = textAlign,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Muted grey that still follows the surrounding line's dimming (current vs. inactive line).
|
||||
val markColor = color.copy(alpha = color.alpha * 0.7f)
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
var cursor = 0
|
||||
matches.forEachIndexed { index, match ->
|
||||
append(text.substring(cursor, match.range.first))
|
||||
// Glue the marked letter to its neighbouring characters so a line never breaks onto it.
|
||||
append(WORD_JOINER)
|
||||
appendInlineContent(id = inlineId(index), alternateText = match.letter)
|
||||
append(WORD_JOINER)
|
||||
cursor = match.range.last + 1
|
||||
}
|
||||
append(text.substring(cursor))
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
val inlineContent = matches
|
||||
.mapIndexed { index, match ->
|
||||
val letter = match.letter
|
||||
// Reserve exactly the letter's advance width (in em, so it is font-scale independent); the
|
||||
// taller placeholder leaves room for the mark. Aligning to the text bottom keeps the
|
||||
// baseline in step with the rest of the line.
|
||||
val letterWidthEm = remember(letter, style) {
|
||||
val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width
|
||||
val fontSizePx = with(density) { style.fontSize.toPx() }
|
||||
letterWidthPx / fontSizePx
|
||||
}
|
||||
|
||||
inlineId(index) to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = letterWidthEm.em,
|
||||
height = 1.7.em,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
|
||||
),
|
||||
) {
|
||||
if (match.isBracketed) {
|
||||
LiaisonLetter(
|
||||
letter = letter,
|
||||
color = markColor,
|
||||
style = style,
|
||||
)
|
||||
} else {
|
||||
MutedLetter(
|
||||
letter = letter,
|
||||
color = markColor,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
color = color,
|
||||
style = style,
|
||||
textAlign = textAlign,
|
||||
inlineContent = inlineContent,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutedLetter(
|
||||
letter: String,
|
||||
color: Color,
|
||||
style: TextStyle,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = MUTED_MARK,
|
||||
color = color,
|
||||
style = style.copy(
|
||||
fontSize = style.fontSize * 0.75f,
|
||||
lineHeight = style.fontSize * 0.75f,
|
||||
),
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
Text(
|
||||
text = letter,
|
||||
color = color,
|
||||
// Tight line height so a bottom glyph's baseline lands where the surrounding line expects it.
|
||||
style = style.copy(fontSize = style.fontSize, lineHeight = style.fontSize),
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiaisonLetter(
|
||||
letter: String,
|
||||
color: Color,
|
||||
style: TextStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 1.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
color = color,
|
||||
letterSpacing = 1.sp,
|
||||
// Tight line height so a bottom glyph's baseline lands where the surrounding line expects it.
|
||||
style = style.copy(
|
||||
fontSize = style.fontSize * 0.75f,
|
||||
lineHeight = style.fontSize * 0.75f,
|
||||
),
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(3.dp),
|
||||
) {
|
||||
drawArc(
|
||||
color = color,
|
||||
startAngle = 180f,
|
||||
sweepAngle = 180f,
|
||||
useCenter = false,
|
||||
style = Stroke(width = 2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MatchResult.letter: String
|
||||
get() = groupValues[1].ifEmpty { groupValues[2] }
|
||||
|
||||
private val MatchResult.isBracketed: Boolean
|
||||
get() = groupValues[1].isEmpty()
|
||||
|
||||
/**
|
||||
* Strips pronunciation marks from a transcription: a muted parenthesised letter becomes a plain
|
||||
* letter, while a bracketed liaison letter is dropped and replaced by a space — except when it
|
||||
* directly follows an apostrophe (e.g. `d'[t]ober`), where it is removed without leaving a space.
|
||||
*/
|
||||
private fun String.withoutMarkedLetters(): String =
|
||||
replace(MARKED_LETTER_REGEX) { match ->
|
||||
val precedingChar = getOrNull(match.range.first - 1)
|
||||
when {
|
||||
!match.isBracketed -> match.letter
|
||||
precedingChar != null && precedingChar in APOSTROPHES -> ""
|
||||
else -> " "
|
||||
}
|
||||
}
|
||||
|
||||
/** Apostrophe characters after which a dropped liaison letter should not leave a space. */
|
||||
private val APOSTROPHES = setOf('\'', '’')
|
||||
|
||||
private fun inlineId(index: Int): String = "marked-letter-$index"
|
||||
|
||||
@Preview(backgroundColor = 0xFFFFFFFF)
|
||||
@Composable
|
||||
private fun TranscriptionTextLiaisonPreview() {
|
||||
MaterialTheme {
|
||||
TranscriptionText(
|
||||
text = "Tomm eo ma(n) divskouarn, penn ma fri",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(backgroundColor = 0xFFFFFFFF)
|
||||
@Composable
|
||||
private fun TranscriptionTextBracketPreview() {
|
||||
MaterialTheme {
|
||||
TranscriptionText(
|
||||
text = "Lârit[t] din, mar plij[k] geneoc'h.",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(backgroundColor = 0xFFFFFFFF)
|
||||
@Composable
|
||||
private fun LiaisonLetterPreview() {
|
||||
MaterialTheme {
|
||||
LiaisonLetter(
|
||||
letter = "D",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package bzh.ajaury.chombev.player.ui.viewmodel
|
||||
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class PlayerUiState(
|
||||
val recordEmoji: String = "",
|
||||
val recordTitle: Phrase = Phrase(transcription = ""),
|
||||
val playerState: PlayerState = PlayerState(),
|
||||
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
|
||||
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
|
||||
) {
|
||||
val canGoToPreviousSentence: Boolean
|
||||
get() = playerState.playbackTiming.position > 2.seconds
|
||||
|
||||
val canGoToNextSentence: Boolean
|
||||
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
|
||||
|
||||
/** True when either playback setting differs from its default, used to highlight the button. */
|
||||
val isPlaybackModified: Boolean
|
||||
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
|
||||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package bzh.ajaury.chombev.player.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.player.domain.AudioSessionManager
|
||||
import bzh.ajaury.chombev.player.domain.PlaybackRepository
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import bzh.ajaury.chombev.records.domain.RecordRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class PlayerViewModel(
|
||||
private val recordId: Int,
|
||||
private val recordRepository: RecordRepository,
|
||||
private val playbackRepository: PlaybackRepository,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val audioSessionManager: AudioSessionManager,
|
||||
private val logger: Logger,
|
||||
) : ViewModel() {
|
||||
private val record = MutableStateFlow<Record?>(null)
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = combine(
|
||||
record,
|
||||
playbackRepository.playerState,
|
||||
preferencesRepository.subtitlePreferences,
|
||||
preferencesRepository.playbackPreferences,
|
||||
) { record, playerState, subtitlePreferences, playbackPreferences ->
|
||||
PlayerUiState(
|
||||
recordEmoji = record?.emoji ?: "",
|
||||
recordTitle = record?.title ?: Phrase(transcription = ""),
|
||||
playerState = playerState,
|
||||
subtitlePreferences = subtitlePreferences,
|
||||
playbackSpeed = playbackPreferences.speed,
|
||||
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5.seconds),
|
||||
initialValue = PlayerUiState(),
|
||||
)
|
||||
|
||||
init {
|
||||
audioSessionManager.activate()
|
||||
}
|
||||
|
||||
fun loadRecord() {
|
||||
viewModelScope.launch {
|
||||
logger.debug("Loading record $recordId")
|
||||
val alreadyLoadedRecord = record.value
|
||||
if (alreadyLoadedRecord != null) {
|
||||
logger.debug("Record $recordId already loaded")
|
||||
playbackRepository.load(record = alreadyLoadedRecord)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val loadedRecord = recordRepository.getRecord(id = recordId)
|
||||
if (loadedRecord == null) {
|
||||
logger.error(message = "Record not found: $recordId")
|
||||
return@launch
|
||||
}
|
||||
|
||||
playbackRepository.load(record = loadedRecord)
|
||||
record.value = loadedRecord
|
||||
}
|
||||
}
|
||||
|
||||
fun performMainPlayAction() {
|
||||
viewModelScope.launch {
|
||||
when (uiState.value.playerState.playbackState) {
|
||||
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
|
||||
PlaybackState.ENDED -> playbackRepository.replay()
|
||||
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun seekToTimePart(progress: Duration) {
|
||||
viewModelScope.launch {
|
||||
playbackRepository.seekTo(progress)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekToSentence(sentenceIndex: Int) {
|
||||
viewModelScope.launch {
|
||||
playbackRepository.seekToSentence(sentenceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPreviousSentence() {
|
||||
viewModelScope.launch {
|
||||
playbackRepository.goToPreviousSentence()
|
||||
}
|
||||
}
|
||||
|
||||
fun goToNextSentence() {
|
||||
viewModelScope.launch {
|
||||
playbackRepository.goToNextSentence()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
val steppedSpeed = (speed * 10).roundToInt() / 10f
|
||||
val clampedSpeed = steppedSpeed.coerceIn(
|
||||
PlaybackPreferences.MIN_SPEED,
|
||||
PlaybackPreferences.MAX_SPEED,
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setPlaybackSpeed(clampedSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBreakBetweenPhrases(seconds: Float) {
|
||||
val clampedSeconds = seconds
|
||||
.roundToInt()
|
||||
.coerceIn(0, PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds.toInt())
|
||||
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setBreakBetweenPhrases(clampedSeconds.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowTranscription(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setShowTranscription(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowTranslation(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setShowTranslation(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowPhoneticMarkers(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setShowPhoneticMarkers(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseSubtitleTextSize() {
|
||||
val current = uiState.value.subtitlePreferences.textScale
|
||||
setSubtitleTextScale(SubtitlePreferences.nextTextScale(current))
|
||||
}
|
||||
|
||||
fun decreaseSubtitleTextSize() {
|
||||
val current = uiState.value.subtitlePreferences.textScale
|
||||
setSubtitleTextScale(SubtitlePreferences.previousTextScale(current))
|
||||
}
|
||||
|
||||
private fun setSubtitleTextScale(scale: Float) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSubtitleTextScale(scale)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
viewModelScope.launch {
|
||||
playbackRepository.stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
audioSessionManager.deactivate()
|
||||
playbackRepository.release()
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package bzh.ajaury.chombev.preferences.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Subtitles
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PreferenceToggle(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit = {},
|
||||
icon: ImageVector? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = checked,
|
||||
role = Role.Switch,
|
||||
onValueChange = onCheckedChange,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreferenceTogglePreview() {
|
||||
MaterialTheme {
|
||||
PreferenceToggle(
|
||||
title = "Subtitle",
|
||||
subtitle = "Subtitle",
|
||||
checked = true,
|
||||
icon = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreferenceToggleWithIconPreview() {
|
||||
MaterialTheme {
|
||||
PreferenceToggle(
|
||||
title = "Subtitle",
|
||||
subtitle = "Subtitle",
|
||||
checked = true,
|
||||
icon = Icons.Default.Subtitles,
|
||||
)
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package bzh.ajaury.chombev.preferences.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_title
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_decrease
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_label
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Bottom sheet letting the user toggle the display of the Breton transcription (BZH) and the
|
||||
* French translation (FR) on the player.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SubtitlePreferencesBottomSheet(
|
||||
subtitlePreferences: SubtitlePreferences,
|
||||
onShowTranscriptionChange: (Boolean) -> Unit = {},
|
||||
onShowTranslationChange: (Boolean) -> Unit = {},
|
||||
onShowPhoneticMarkerChange: (Boolean) -> Unit = {},
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
SubtitlePreferencesContent(
|
||||
subtitlePreferences = subtitlePreferences,
|
||||
onShowTranscriptionChange = onShowTranscriptionChange,
|
||||
onShowTranslationChange = onShowTranslationChange,
|
||||
onShowMarkedLettersChange = onShowPhoneticMarkerChange,
|
||||
onTextSizeDecrease = onTextSizeDecrease,
|
||||
onTextSizeIncrease = onTextSizeIncrease,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtitlePreferencesContent(
|
||||
subtitlePreferences: SubtitlePreferences,
|
||||
onShowTranscriptionChange: (Boolean) -> Unit = {},
|
||||
onShowTranslationChange: (Boolean) -> Unit = {},
|
||||
onShowMarkedLettersChange: (Boolean) -> Unit = {},
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.subtitle_preferences_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
PreferenceToggle(
|
||||
title = stringResource(Res.string.subtitle_transcription_label),
|
||||
subtitle = stringResource(Res.string.subtitle_transcription_description),
|
||||
checked = subtitlePreferences.showTranscription,
|
||||
onCheckedChange = onShowTranscriptionChange,
|
||||
)
|
||||
PreferenceToggle(
|
||||
title = stringResource(Res.string.subtitle_translation_label),
|
||||
subtitle = stringResource(Res.string.subtitle_translation_description),
|
||||
checked = subtitlePreferences.showTranslation,
|
||||
onCheckedChange = onShowTranslationChange,
|
||||
)
|
||||
PreferenceToggle(
|
||||
title = stringResource(Res.string.subtitle_marked_letters_label),
|
||||
subtitle = stringResource(Res.string.subtitle_marked_letters_description),
|
||||
checked = subtitlePreferences.showPhoneticMarkers,
|
||||
onCheckedChange = onShowMarkedLettersChange,
|
||||
)
|
||||
SubtitleTextSizeControl(
|
||||
textScale = subtitlePreferences.textScale,
|
||||
onDecrease = onTextSizeDecrease,
|
||||
onIncrease = onTextSizeIncrease,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtitleTextSizeControl(
|
||||
textScale: Float,
|
||||
onDecrease: () -> Unit = {},
|
||||
onIncrease: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.subtitle_text_size_label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDecrease,
|
||||
enabled = textScale > SubtitlePreferences.TEXT_SCALE_STEPS.first(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Remove,
|
||||
contentDescription = stringResource(Res.string.subtitle_text_size_decrease),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatTextScale(textScale),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onIncrease,
|
||||
enabled = textScale < SubtitlePreferences.TEXT_SCALE_STEPS.last(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(Res.string.subtitle_text_size_increase),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a text-scale multiplier as a short label, e.g. 1.0 -> "x1.0", 1.2 -> "x1.2". */
|
||||
private fun formatTextScale(scale: Float): String {
|
||||
val tenths = (scale * 10).roundToInt()
|
||||
return "x${tenths / 10}.${tenths % 10}"
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SubtitlePreferencesContentPreview() {
|
||||
MaterialTheme {
|
||||
SubtitlePreferencesContent(
|
||||
subtitlePreferences = SubtitlePreferences(
|
||||
showTranscription = true,
|
||||
showTranslation = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package bzh.ajaury.chombev.records.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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 bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
|
||||
@Composable
|
||||
fun RecordView(
|
||||
record: Record,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = record.index.toString().padStart(2),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
|
||||
Text(text = record.emoji)
|
||||
Column {
|
||||
Text(text = record.title.transcription)
|
||||
|
||||
record.title.translation?.let { translation ->
|
||||
Text(
|
||||
text = translation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RecordViewPreview() {
|
||||
RecordView(
|
||||
record =
|
||||
Record(
|
||||
id = 1,
|
||||
index = 1,
|
||||
emoji = "🙏",
|
||||
title = Phrase(
|
||||
transcription = "Sample Record",
|
||||
translation = "Exemple d'enregistrement",
|
||||
),
|
||||
audioResourcePath = "files/Chom_bev_01.ogg",
|
||||
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package bzh.ajaury.chombev.records.ui
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun RecordsScreen(
|
||||
contentPadding: PaddingValues,
|
||||
onRecordClick: (Record) -> Unit,
|
||||
viewModel: RecordsViewModel = koinViewModel(),
|
||||
) {
|
||||
val records by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
RecordsScreen(
|
||||
records = records,
|
||||
contentPadding = contentPadding,
|
||||
onRecordClick = onRecordClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecordsScreen(
|
||||
records: List<Record>,
|
||||
contentPadding: PaddingValues,
|
||||
onRecordClick: (Record) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = records,
|
||||
key = { _, item -> item.id },
|
||||
) { index, item ->
|
||||
RecordView(
|
||||
record = item,
|
||||
onClick = { onRecordClick(item) },
|
||||
)
|
||||
|
||||
if (index < records.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RecordsScreenPreview() {
|
||||
MaterialTheme {
|
||||
RecordsScreen(
|
||||
records = listOf(
|
||||
Record(
|
||||
id = 1,
|
||||
index = 1,
|
||||
emoji = "🙏",
|
||||
title = Phrase(transcription = "An onestiz", translation = "La politesse"),
|
||||
audioResourcePath = "files/Chom_bev_01.m4a",
|
||||
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
|
||||
),
|
||||
Record(
|
||||
id = 2,
|
||||
index = 2,
|
||||
emoji = "👋",
|
||||
title = Phrase(transcription = "En em brezantiñ", translation = "Se présenter"),
|
||||
audioResourcePath = "files/Chom_bev_02.m4a",
|
||||
subtitleResourcePath = "files/Chom_bev_02_st_bzh.lrc",
|
||||
),
|
||||
),
|
||||
contentPadding = PaddingValues(),
|
||||
onRecordClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package bzh.ajaury.chombev.records.ui.di
|
||||
|
||||
import bzh.ajaury.chombev.player.di.audioPlayerModule
|
||||
import bzh.ajaury.chombev.player.di.playbackModule
|
||||
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
||||
import bzh.ajaury.chombev.preferences.di.preferencesModule
|
||||
import bzh.ajaury.chombev.records.di.recordsModule
|
||||
import bzh.ajaury.chombev.records.ui.viewmodel.RecordsViewModel
|
||||
import bzh.ajaury.chombev.resources.di.resourcesModule
|
||||
import bzh.ajaury.chombev.subtitle.di.subtitleModule
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* DI for the records feature: the record list, the player, and subtitle preferences UI.
|
||||
*/
|
||||
val recordsFeatureModule: Module = module {
|
||||
|
||||
includes(
|
||||
playbackModule,
|
||||
preferencesModule,
|
||||
recordsModule,
|
||||
resourcesModule,
|
||||
audioPlayerModule,
|
||||
subtitleModule,
|
||||
)
|
||||
|
||||
viewModelOf(::RecordsViewModel)
|
||||
viewModelOf(::PlayerViewModel)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package bzh.ajaury.chombev.records.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.records.domain.RecordRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class RecordsViewModel(
|
||||
recordRepository: RecordRepository,
|
||||
) : ViewModel() {
|
||||
val uiState: StateFlow<List<Record>> = recordRepository
|
||||
.getRecords()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package bzh.ajaury.chombev.web.di
|
||||
|
||||
import bzh.ajaury.chombev.resources.di.resourcesModule
|
||||
import bzh.ajaury.chombev.web.ui.viewmodel.WebViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val webviewModule: Module = module {
|
||||
includes(resourcesModule)
|
||||
viewModelOf(::WebViewModel)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user