new: add playback speed control
This commit is contained in:
+4
@@ -61,6 +61,10 @@ internal class AndroidAudioPlayer(
|
|||||||
player.prepare()
|
player.prepare()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) {
|
||||||
|
player.setPlaybackSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ interface AudioPlayer {
|
|||||||
fun release()
|
fun release()
|
||||||
|
|
||||||
fun load(uri: String)
|
fun load(uri: String)
|
||||||
|
|
||||||
|
/** Sets the playback speed multiplier (e.g. 1.0 for normal speed, 0.5 for half speed). */
|
||||||
|
fun setSpeed(speed: Float)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -6,14 +6,16 @@ import kotlinx.cinterop.ExperimentalForeignApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import platform.AVFoundation.AVAudioTimePitchAlgorithmTimeDomain
|
||||||
import platform.AVFoundation.AVPlayer
|
import platform.AVFoundation.AVPlayer
|
||||||
import platform.AVFoundation.AVPlayerItem
|
import platform.AVFoundation.AVPlayerItem
|
||||||
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
|
import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification
|
||||||
|
import platform.AVFoundation.audioTimePitchAlgorithm
|
||||||
import platform.AVFoundation.currentItem
|
import platform.AVFoundation.currentItem
|
||||||
import platform.AVFoundation.currentTime
|
import platform.AVFoundation.currentTime
|
||||||
import platform.AVFoundation.duration
|
import platform.AVFoundation.duration
|
||||||
import platform.AVFoundation.pause
|
import platform.AVFoundation.pause
|
||||||
import platform.AVFoundation.play
|
import platform.AVFoundation.rate
|
||||||
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||||
import platform.AVFoundation.seekToTime
|
import platform.AVFoundation.seekToTime
|
||||||
import platform.CoreMedia.CMTimeGetSeconds
|
import platform.CoreMedia.CMTimeGetSeconds
|
||||||
@@ -29,6 +31,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
internal class IosAudioPlayer : AudioPlayer {
|
internal class IosAudioPlayer : AudioPlayer {
|
||||||
private var player: AVPlayer? = null
|
private var player: AVPlayer? = null
|
||||||
private var endObserver: NSObjectProtocol? = null
|
private var endObserver: NSObjectProtocol? = null
|
||||||
|
private var speed: Float = 1f
|
||||||
|
|
||||||
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||||
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
||||||
@@ -63,6 +66,8 @@ internal class IosAudioPlayer : AudioPlayer {
|
|||||||
|
|
||||||
val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri")
|
val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri")
|
||||||
val item = AVPlayerItem(uRL = nsUrl)
|
val item = AVPlayerItem(uRL = nsUrl)
|
||||||
|
// Time-domain stretching preserves the pitch when the rate is below 1.0.
|
||||||
|
item.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithmTimeDomain
|
||||||
player = AVPlayer(playerItem = item)
|
player = AVPlayer(playerItem = item)
|
||||||
|
|
||||||
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
endObserver = NSNotificationCenter.defaultCenter.addObserverForName(
|
||||||
@@ -75,10 +80,18 @@ internal class IosAudioPlayer : AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
player?.play()
|
// Setting a positive rate resumes playback; using the stored speed keeps the chosen tempo.
|
||||||
|
player?.rate = speed
|
||||||
_playbackState.value = PlaybackState.PLAYING
|
_playbackState.value = PlaybackState.PLAYING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) {
|
||||||
|
this.speed = speed
|
||||||
|
if (_playbackState.value == PlaybackState.PLAYING) {
|
||||||
|
player?.rate = speed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
_playbackState.value = PlaybackState.PAUSED
|
_playbackState.value = PlaybackState.PAUSED
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import java.net.URI
|
|||||||
import javax.sound.sampled.AudioFormat
|
import javax.sound.sampled.AudioFormat
|
||||||
import javax.sound.sampled.AudioSystem
|
import javax.sound.sampled.AudioSystem
|
||||||
import javax.sound.sampled.Clip
|
import javax.sound.sampled.Clip
|
||||||
|
import javax.sound.sampled.FloatControl
|
||||||
import javax.sound.sampled.LineEvent
|
import javax.sound.sampled.LineEvent
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.microseconds
|
import kotlin.time.Duration.Companion.microseconds
|
||||||
@@ -26,6 +27,9 @@ internal class JvmAudioPlayer : AudioPlayer {
|
|||||||
|
|
||||||
private var stoppedByUser = false
|
private var stoppedByUser = false
|
||||||
|
|
||||||
|
private var speed: Float = 1f
|
||||||
|
private var baseSampleRate: Float = 0f
|
||||||
|
|
||||||
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||||
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
||||||
|
|
||||||
@@ -77,6 +81,23 @@ internal class JvmAudioPlayer : AudioPlayer {
|
|||||||
}
|
}
|
||||||
open(decodedInput)
|
open(decodedInput)
|
||||||
}
|
}
|
||||||
|
baseSampleRate = pcmFormat.sampleRate
|
||||||
|
applySpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) {
|
||||||
|
this.speed = speed
|
||||||
|
applySpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts the tempo through the clip's sample-rate control. Unlike the other platforms this does
|
||||||
|
* not preserve pitch, and it is a no-op when the mixer does not expose the control.
|
||||||
|
*/
|
||||||
|
private fun applySpeed() {
|
||||||
|
val clip = clip ?: return
|
||||||
|
if (baseSampleRate <= 0f || !clip.isControlSupported(FloatControl.Type.SAMPLE_RATE)) return
|
||||||
|
(clip.getControl(FloatControl.Type.SAMPLE_RATE) as FloatControl).value = baseSampleRate * speed
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
|
|
||||||
internal class WebAudioPlayer : AudioPlayer {
|
internal class WebAudioPlayer : AudioPlayer {
|
||||||
private var audio: HTMLAudioElement? = null
|
private var audio: HTMLAudioElement? = null
|
||||||
|
private var speed: Float = 1f
|
||||||
|
|
||||||
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
private val _playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||||
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
|
||||||
@@ -31,12 +32,19 @@ internal class WebAudioPlayer : AudioPlayer {
|
|||||||
|
|
||||||
audio = (document.createElement("audio") as HTMLAudioElement).apply {
|
audio = (document.createElement("audio") as HTMLAudioElement).apply {
|
||||||
src = uri
|
src = uri
|
||||||
|
playbackRate = speed.toDouble()
|
||||||
addEventListener("play") { _playbackState.value = PlaybackState.PLAYING }
|
addEventListener("play") { _playbackState.value = PlaybackState.PLAYING }
|
||||||
addEventListener("ended") { _playbackState.value = PlaybackState.ENDED }
|
addEventListener("ended") { _playbackState.value = PlaybackState.ENDED }
|
||||||
addEventListener("pause") { _playbackState.value = PlaybackState.PAUSED }
|
addEventListener("pause") { _playbackState.value = PlaybackState.PAUSED }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) {
|
||||||
|
// Browsers preserve pitch (preservesPitch defaults to true) when changing playbackRate.
|
||||||
|
this.speed = speed
|
||||||
|
audio?.playbackRate = speed.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
override fun play() {
|
override fun play() {
|
||||||
audio?.play()
|
audio?.play()
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -136,6 +136,10 @@ internal class PlaybackRepositoryImpl(
|
|||||||
audioPlayer.seekTo(position)
|
audioPlayer.seekTo(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) {
|
||||||
|
audioPlayer.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
override fun seekToSentence(sentenceIndex: Int) {
|
override fun seekToSentence(sentenceIndex: Int) {
|
||||||
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
||||||
audioPlayer.seekTo(matchingSubtitleLine.startTime)
|
audioPlayer.seekTo(matchingSubtitleLine.startTime)
|
||||||
|
|||||||
+2
@@ -25,6 +25,8 @@ interface PlaybackRepository {
|
|||||||
|
|
||||||
fun seekTo(position: Duration)
|
fun seekTo(position: Duration)
|
||||||
|
|
||||||
|
fun setSpeed(speed: Float)
|
||||||
|
|
||||||
fun seekToSentence(sentenceIndex: Int)
|
fun seekToSentence(sentenceIndex: Int)
|
||||||
|
|
||||||
fun goToPreviousSentence()
|
fun goToPreviousSentence()
|
||||||
|
|||||||
+12
-4
@@ -1,6 +1,7 @@
|
|||||||
package fr.ajaury.gwenedeg.preferences.data
|
package fr.ajaury.gwenedeg.preferences.data
|
||||||
|
|
||||||
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
|
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -8,14 +9,21 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
internal class PreferencesRepositoryImpl : PreferencesRepository {
|
internal class PreferencesRepositoryImpl : PreferencesRepository {
|
||||||
private val state = MutableStateFlow(SubtitlePreferences())
|
private val subtitleState = MutableStateFlow(SubtitlePreferences())
|
||||||
override val subtitlePreferences: StateFlow<SubtitlePreferences> = state.asStateFlow()
|
override val subtitlePreferences: StateFlow<SubtitlePreferences> = subtitleState.asStateFlow()
|
||||||
|
|
||||||
|
private val playbackState = MutableStateFlow(PlaybackPreferences())
|
||||||
|
override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow()
|
||||||
|
|
||||||
override fun setShowTranscription(enabled: Boolean) {
|
override fun setShowTranscription(enabled: Boolean) {
|
||||||
state.update { it.copy(showTranscription = enabled) }
|
subtitleState.update { it.copy(showTranscription = enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setShowTranslation(enabled: Boolean) {
|
override fun setShowTranslation(enabled: Boolean) {
|
||||||
state.update { it.copy(showTranslation = enabled) }
|
subtitleState.update { it.copy(showTranslation = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
playbackState.update { it.copy(speed = speed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1,12 +1,17 @@
|
|||||||
package fr.ajaury.gwenedeg.preferences.domain
|
package fr.ajaury.gwenedeg.preferences.domain
|
||||||
|
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface PreferencesRepository {
|
interface PreferencesRepository {
|
||||||
val subtitlePreferences: StateFlow<SubtitlePreferences>
|
val subtitlePreferences: StateFlow<SubtitlePreferences>
|
||||||
|
|
||||||
|
val playbackPreferences: StateFlow<PlaybackPreferences>
|
||||||
|
|
||||||
fun setShowTranscription(enabled: Boolean)
|
fun setShowTranscription(enabled: Boolean)
|
||||||
|
|
||||||
fun setShowTranslation(enabled: Boolean)
|
fun setShowTranslation(enabled: Boolean)
|
||||||
|
|
||||||
|
fun setPlaybackSpeed(speed: Float)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package fr.ajaury.gwenedeg.preferences.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User preferences controlling audio playback.
|
||||||
|
*
|
||||||
|
* @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in
|
||||||
|
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed.
|
||||||
|
*/
|
||||||
|
data class PlaybackPreferences(
|
||||||
|
val speed: Float = DEFAULT_SPEED,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_SPEED = 1f
|
||||||
|
const val MIN_SPEED = 0.5f
|
||||||
|
const val MAX_SPEED = 1f
|
||||||
|
const val SPEED_STEP = 0.1f
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package fr.ajaury.gwenedeg.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.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.Slider
|
||||||
|
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 fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet letting the user pick the audio playback speed between
|
||||||
|
* [PlaybackPreferences.MIN_SPEED] and [PlaybackPreferences.MAX_SPEED].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PlaybackSpeedBottomSheet(
|
||||||
|
speed: Float,
|
||||||
|
onSpeedChange: (Float) -> Unit = {},
|
||||||
|
onDismiss: () -> Unit = {},
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
PlaybackSpeedContent(
|
||||||
|
speed = speed,
|
||||||
|
onSpeedChange = onSpeedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackSpeedContent(
|
||||||
|
speed: Float,
|
||||||
|
onSpeedChange: (Float) -> Unit = {},
|
||||||
|
) {
|
||||||
|
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
|
||||||
|
val innerSteps = with(PlaybackPreferences) {
|
||||||
|
(((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Vitesse de lecture",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatPlaybackSpeed(speed),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = speed,
|
||||||
|
onValueChange = onSpeedChange,
|
||||||
|
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
|
||||||
|
steps = innerSteps,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a speed multiplier as a short label, e.g. 1.0 → "x1", 0.7 → "x0.7". */
|
||||||
|
internal fun formatPlaybackSpeed(speed: Float): String {
|
||||||
|
val tenths = (speed * 10).roundToInt()
|
||||||
|
return if (tenths % 10 == 0) "x${tenths / 10}" else "x${tenths / 10}.${tenths % 10}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackSpeedContentPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlaybackSpeedContent(speed = 0.7f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,24 @@
|
|||||||
package fr.ajaury.gwenedeg.player.ui
|
package fr.ajaury.gwenedeg.player.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
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.material.icons.filled.SkipNext
|
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.runtime.Composable
|
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.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.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import fr.ajaury.gwenedeg.player.model.PlaybackState
|
import fr.ajaury.gwenedeg.player.model.PlaybackState
|
||||||
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
|
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.components.NextButton
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.components.PlayButton
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.components.PlaybackProgress
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.components.PreviousButton
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.components.SpeedPreferenceButton
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -35,86 +26,68 @@ fun PlayerControl(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
playbackState: PlaybackState,
|
playbackState: PlaybackState,
|
||||||
playbackTiming: PlaybackTiming,
|
playbackTiming: PlaybackTiming,
|
||||||
|
canGoToPreviousSentence: Boolean,
|
||||||
|
canGoToNextSentence: Boolean,
|
||||||
|
isSpeedActive: Boolean,
|
||||||
|
playbackSpeed: Float,
|
||||||
onSeek: (Float) -> Unit = {},
|
onSeek: (Float) -> Unit = {},
|
||||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||||
onPreviousSentenceClicked: () -> Unit = {},
|
onPreviousSentenceClicked: () -> Unit = {},
|
||||||
onNextSentenceClicked: () -> Unit = {},
|
onNextSentenceClicked: () -> Unit = {},
|
||||||
canGoToPreviousSentence: Boolean = true,
|
onSpeedClicked: () -> Unit = {},
|
||||||
canGoToNextSentence: Boolean = true,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
var seekProgress by remember { mutableStateOf<Float?>(null) }
|
PlaybackProgress(
|
||||||
Slider(
|
playbackTiming = playbackTiming,
|
||||||
value = seekProgress ?: playbackTiming.progress,
|
onSeek = onSeek,
|
||||||
onValueChange = { seekProgress = it },
|
|
||||||
onValueChangeFinished = {
|
|
||||||
seekProgress?.let { fraction ->
|
|
||||||
if (playbackTiming.durationMs > 0) {
|
|
||||||
onSeek(fraction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seekProgress = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
) {
|
||||||
IconButton(
|
// Left region: the speed button is centered in the space left of the transport group.
|
||||||
modifier = Modifier.size(60.dp),
|
Box(
|
||||||
enabled = canGoToPreviousSentence,
|
modifier = Modifier.weight(1f),
|
||||||
onClick = onPreviousSentenceClicked,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
SpeedPreferenceButton(
|
||||||
imageVector = Icons.Default.SkipPrevious,
|
isSpeedActive = isSpeedActive,
|
||||||
contentDescription = "Previous sentence",
|
playbackSpeed = playbackSpeed,
|
||||||
|
onSpeedClicked = onSpeedClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
// Transport group: centered in the full width by the equal-weight cells on either side.
|
||||||
modifier = Modifier.size(60.dp),
|
Row(
|
||||||
contentPadding = PaddingValues(0.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onClick = onMainPlayActionButtonClicked,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
PreviousButton(
|
||||||
imageVector = playbackState.toActionIcon(),
|
canGoToPreviousSentence = canGoToPreviousSentence,
|
||||||
contentDescription = playbackState.toActionContentDescription(),
|
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlayButton(
|
||||||
|
playbackState = playbackState,
|
||||||
|
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||||
|
)
|
||||||
|
|
||||||
|
NextButton(
|
||||||
|
canGoToNextSentence = canGoToNextSentence,
|
||||||
|
onNextSentenceClicked = onNextSentenceClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
// Right region: mirrors the left weight so the transport group stays centered.
|
||||||
modifier = Modifier.size(60.dp),
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
enabled = canGoToNextSentence,
|
|
||||||
onClick = onNextSentenceClicked,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.SkipNext,
|
|
||||||
contentDescription = "Next sentence",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun PlaybackState.toActionIcon(): ImageVector =
|
|
||||||
when (this) {
|
|
||||||
PlaybackState.PLAYING -> Icons.Default.Pause
|
|
||||||
PlaybackState.ENDED -> Icons.Default.Replay
|
|
||||||
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PlaybackState.toActionContentDescription(): String =
|
|
||||||
when (this) {
|
|
||||||
PlaybackState.PLAYING -> "Pause"
|
|
||||||
PlaybackState.ENDED -> "Replay"
|
|
||||||
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
@@ -123,6 +96,10 @@ private fun PlayerControlPreview() {
|
|||||||
PlayerControl(
|
PlayerControl(
|
||||||
playbackState = PlaybackState.PLAYING,
|
playbackState = PlaybackState.PLAYING,
|
||||||
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
||||||
|
canGoToPreviousSentence = true,
|
||||||
|
canGoToNextSentence = true,
|
||||||
|
isSpeedActive = false,
|
||||||
|
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +112,24 @@ private fun PlayerControlFirstSentencePreview() {
|
|||||||
playbackState = PlaybackState.PLAYING,
|
playbackState = PlaybackState.PLAYING,
|
||||||
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
||||||
canGoToPreviousSentence = false,
|
canGoToPreviousSentence = false,
|
||||||
|
canGoToNextSentence = true,
|
||||||
|
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
|
||||||
|
isSpeedActive = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlayerControlActiveSpeedPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlayerControl(
|
||||||
|
playbackState = PlaybackState.PLAYING,
|
||||||
|
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
||||||
|
canGoToPreviousSentence = true,
|
||||||
|
canGoToNextSentence = true,
|
||||||
|
playbackSpeed = 0.7f,
|
||||||
|
isSpeedActive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +141,10 @@ private fun PlayerControlLastSentencePreview() {
|
|||||||
PlayerControl(
|
PlayerControl(
|
||||||
playbackState = PlaybackState.PLAYING,
|
playbackState = PlaybackState.PLAYING,
|
||||||
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
|
||||||
|
canGoToPreviousSentence = true,
|
||||||
canGoToNextSentence = false,
|
canGoToNextSentence = false,
|
||||||
|
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
|
||||||
|
isSpeedActive = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import fr.ajaury.gwenedeg.player.model.PlaybackTiming
|
|||||||
import fr.ajaury.gwenedeg.player.model.PlayerState
|
import fr.ajaury.gwenedeg.player.model.PlayerState
|
||||||
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
|
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState
|
||||||
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.preferences.ui.SubtitlePreferencesBottomSheet
|
import fr.ajaury.gwenedeg.preferences.ui.SubtitlePreferencesBottomSheet
|
||||||
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
|
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
|
||||||
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
@@ -64,6 +65,7 @@ fun PlayerScreen(
|
|||||||
onNextSentenceClicked = viewModel::goToNextSentence,
|
onNextSentenceClicked = viewModel::goToNextSentence,
|
||||||
onShowTranscriptionChange = viewModel::setShowTranscription,
|
onShowTranscriptionChange = viewModel::setShowTranscription,
|
||||||
onShowTranslationChange = viewModel::setShowTranslation,
|
onShowTranslationChange = viewModel::setShowTranslation,
|
||||||
|
onSpeedChange = viewModel::setPlaybackSpeed,
|
||||||
onBackClicked = onBackClicked,
|
onBackClicked = onBackClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,9 +81,11 @@ fun PlayerScreen(
|
|||||||
onNextSentenceClicked: () -> Unit = {},
|
onNextSentenceClicked: () -> Unit = {},
|
||||||
onShowTranscriptionChange: (Boolean) -> Unit = {},
|
onShowTranscriptionChange: (Boolean) -> Unit = {},
|
||||||
onShowTranslationChange: (Boolean) -> Unit = {},
|
onShowTranslationChange: (Boolean) -> Unit = {},
|
||||||
|
onSpeedChange: (Float) -> Unit = {},
|
||||||
onBackClicked: () -> Unit = {},
|
onBackClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showPreferences by remember { mutableStateOf(false) }
|
var showPreferences by remember { mutableStateOf(false) }
|
||||||
|
var showSpeed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -146,12 +150,15 @@ fun PlayerScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
playbackState = uiState.playerState.playbackState,
|
playbackState = uiState.playerState.playbackState,
|
||||||
playbackTiming = uiState.playerState.playbackTiming,
|
playbackTiming = uiState.playerState.playbackTiming,
|
||||||
|
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
|
||||||
|
canGoToNextSentence = uiState.canGoToNextSentence,
|
||||||
|
playbackSpeed = uiState.playbackSpeed,
|
||||||
|
isSpeedActive = uiState.isSpeedActive,
|
||||||
onSeek = onSeekToTimePart,
|
onSeek = onSeekToTimePart,
|
||||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||||
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||||
onNextSentenceClicked = onNextSentenceClicked,
|
onNextSentenceClicked = onNextSentenceClicked,
|
||||||
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
|
onSpeedClicked = { showSpeed = true },
|
||||||
canGoToNextSentence = uiState.canGoToNextSentence,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +170,14 @@ fun PlayerScreen(
|
|||||||
onDismiss = { showPreferences = false },
|
onDismiss = { showPreferences = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSpeed) {
|
||||||
|
PlaybackSpeedBottomSheet(
|
||||||
|
speed = uiState.playbackSpeed,
|
||||||
|
onSpeedChange = onSpeedChange,
|
||||||
|
onDismiss = { showSpeed = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package fr.ajaury.gwenedeg.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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NextButton(
|
||||||
|
canGoToNextSentence: Boolean,
|
||||||
|
onNextSentenceClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
enabled = canGoToNextSentence,
|
||||||
|
onClick = onNextSentenceClicked,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SkipNext,
|
||||||
|
contentDescription = "Next sentence",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun NextButtonCanGoToNextSentencePreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
NextButton(
|
||||||
|
canGoToNextSentence = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun NextButtonCanNotGoToNextSentencePreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
NextButton(
|
||||||
|
canGoToNextSentence = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package fr.ajaury.gwenedeg.player.ui.components
|
||||||
|
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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 fr.ajaury.gwenedeg.player.model.PlaybackState
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlayButton(
|
||||||
|
playbackState: PlaybackState,
|
||||||
|
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
onClick = onMainPlayActionButtonClicked,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = playbackState.toActionIcon(),
|
||||||
|
contentDescription = playbackState.toActionContentDescription(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlaybackState.toActionIcon(): ImageVector =
|
||||||
|
when (this) {
|
||||||
|
PlaybackState.PLAYING -> Icons.Default.Pause
|
||||||
|
PlaybackState.ENDED -> Icons.Default.Replay
|
||||||
|
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlaybackState.toActionContentDescription(): String =
|
||||||
|
when (this) {
|
||||||
|
PlaybackState.PLAYING -> "Pause"
|
||||||
|
PlaybackState.ENDED -> "Replay"
|
||||||
|
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlayButtonPlayingPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlayButton(
|
||||||
|
playbackState = PlaybackState.PLAYING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlayButtonPausedPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlayButton(
|
||||||
|
playbackState = PlaybackState.PAUSED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlayButtonEndedPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlayButton(
|
||||||
|
playbackState = PlaybackState.ENDED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlayButtonIdlePreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlayButton(
|
||||||
|
playbackState = PlaybackState.IDLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package fr.ajaury.gwenedeg.player.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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 fr.ajaury.gwenedeg.player.model.PlaybackTiming
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackProgress(
|
||||||
|
playbackTiming: PlaybackTiming,
|
||||||
|
onSeek: (Float) -> Unit = {},
|
||||||
|
) {
|
||||||
|
var seekProgress by remember { mutableStateOf<Float?>(null) }
|
||||||
|
Slider(
|
||||||
|
value = seekProgress ?: playbackTiming.progress,
|
||||||
|
onValueChange = { seekProgress = it },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
seekProgress?.let { fraction ->
|
||||||
|
if (playbackTiming.durationMs > 0) {
|
||||||
|
onSeek(fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seekProgress = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackProgressPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlaybackProgress(
|
||||||
|
playbackTiming = PlaybackTiming(0, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackProgressInProgressPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlaybackProgress(
|
||||||
|
playbackTiming = PlaybackTiming(7000, 10000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackProgressEndedPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PlaybackProgress(
|
||||||
|
playbackTiming = PlaybackTiming(10000, 10000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package fr.ajaury.gwenedeg.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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreviousButton(
|
||||||
|
canGoToPreviousSentence: Boolean,
|
||||||
|
onPreviousSentenceClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
enabled = canGoToPreviousSentence,
|
||||||
|
onClick = onPreviousSentenceClicked,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SkipPrevious,
|
||||||
|
contentDescription = "Previous sentence",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PreviousButtonCanGoToPreviousSentencePreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PreviousButton(
|
||||||
|
canGoToPreviousSentence = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun PreviousButtonCanNotGoToPreviousSentencePreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
PreviousButton(
|
||||||
|
canGoToPreviousSentence = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package fr.ajaury.gwenedeg.player.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import fr.ajaury.gwenedeg.player.ui.formatPlaybackSpeed
|
||||||
|
import fr.ajaury.gwenedeg.theme.GwenedegTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpeedPreferenceButton(
|
||||||
|
isSpeedActive: Boolean,
|
||||||
|
playbackSpeed: Float,
|
||||||
|
onSpeedClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
onClick = onSpeedClicked,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = if (isSpeedActive) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
LocalContentColor.current
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatPlaybackSpeed(playbackSpeed),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun SpeedPreferenceButtonDefaultPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
SpeedPreferenceButton(
|
||||||
|
isSpeedActive = false,
|
||||||
|
playbackSpeed = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun SpeedPreferenceButtonPreview() {
|
||||||
|
GwenedegTheme {
|
||||||
|
SpeedPreferenceButton(
|
||||||
|
isSpeedActive = true,
|
||||||
|
playbackSpeed = 0.7f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,21 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
|
|||||||
|
|
||||||
import fr.ajaury.gwenedeg.core.model.Phrase
|
import fr.ajaury.gwenedeg.core.model.Phrase
|
||||||
import fr.ajaury.gwenedeg.player.model.PlayerState
|
import fr.ajaury.gwenedeg.player.model.PlayerState
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
val recordTitle: Phrase = Phrase(transcription = ""),
|
val recordTitle: Phrase = Phrase(transcription = ""),
|
||||||
val playerState: PlayerState = PlayerState(),
|
val playerState: PlayerState = PlayerState(),
|
||||||
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||||
|
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
|
||||||
) {
|
) {
|
||||||
val canGoToPreviousSentence: Boolean
|
val canGoToPreviousSentence: Boolean
|
||||||
get() = playerState.playbackTiming.positionMs > 2000
|
get() = playerState.playbackTiming.positionMs > 2000
|
||||||
|
|
||||||
val canGoToNextSentence: Boolean
|
val canGoToNextSentence: Boolean
|
||||||
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
|
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
|
||||||
|
|
||||||
|
val isSpeedActive: Boolean
|
||||||
|
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -8,6 +8,7 @@ import fr.ajaury.gwenedeg.player.domain.AudioSessionManager
|
|||||||
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
|
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
|
||||||
import fr.ajaury.gwenedeg.player.model.PlaybackState
|
import fr.ajaury.gwenedeg.player.model.PlaybackState
|
||||||
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
|
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
|
||||||
|
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
|
||||||
import fr.ajaury.gwenedeg.records.domain.RecordRepository
|
import fr.ajaury.gwenedeg.records.domain.RecordRepository
|
||||||
import fr.ajaury.gwenedeg.records.model.Record
|
import fr.ajaury.gwenedeg.records.model.Record
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.WhileSubscribed
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -34,11 +36,13 @@ class PlayerViewModel(
|
|||||||
record,
|
record,
|
||||||
playbackRepository.playerState,
|
playbackRepository.playerState,
|
||||||
preferencesRepository.subtitlePreferences,
|
preferencesRepository.subtitlePreferences,
|
||||||
) { record, playerState, subtitlePreferences ->
|
preferencesRepository.playbackPreferences,
|
||||||
|
) { record, playerState, subtitlePreferences, playbackPreferences ->
|
||||||
PlayerUiState(
|
PlayerUiState(
|
||||||
recordTitle = record?.title ?: Phrase(transcription = ""),
|
recordTitle = record?.title ?: Phrase(transcription = ""),
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
subtitlePreferences = subtitlePreferences,
|
subtitlePreferences = subtitlePreferences,
|
||||||
|
playbackSpeed = playbackPreferences.speed,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -57,6 +61,7 @@ class PlayerViewModel(
|
|||||||
if (alreadyLoadedRecord != null) {
|
if (alreadyLoadedRecord != null) {
|
||||||
logger.debug("Record $recordId already loaded")
|
logger.debug("Record $recordId already loaded")
|
||||||
playbackRepository.load(record = alreadyLoadedRecord)
|
playbackRepository.load(record = alreadyLoadedRecord)
|
||||||
|
applyCurrentSpeed()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +72,15 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
playbackRepository.load(record = loadedRecord)
|
playbackRepository.load(record = loadedRecord)
|
||||||
|
applyCurrentSpeed()
|
||||||
record.value = loadedRecord
|
record.value = loadedRecord
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyCurrentSpeed() {
|
||||||
|
playbackRepository.setSpeed(preferencesRepository.playbackPreferences.value.speed)
|
||||||
|
}
|
||||||
|
|
||||||
fun performMainPlayAction() {
|
fun performMainPlayAction() {
|
||||||
when (uiState.value.playerState.playbackState) {
|
when (uiState.value.playerState.playbackState) {
|
||||||
PlaybackState.PLAYING -> playbackRepository.pause()
|
PlaybackState.PLAYING -> playbackRepository.pause()
|
||||||
@@ -97,6 +107,16 @@ class PlayerViewModel(
|
|||||||
playbackRepository.goToNextSentence()
|
playbackRepository.goToNextSentence()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlaybackSpeed(speed: Float) {
|
||||||
|
val steppedSpeed = (speed * 10).roundToInt() / 10f
|
||||||
|
val clampedSpeed = steppedSpeed.coerceIn(
|
||||||
|
PlaybackPreferences.MIN_SPEED,
|
||||||
|
PlaybackPreferences.MAX_SPEED,
|
||||||
|
)
|
||||||
|
preferencesRepository.setPlaybackSpeed(clampedSpeed)
|
||||||
|
playbackRepository.setSpeed(clampedSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
fun setShowTranscription(enabled: Boolean) {
|
fun setShowTranscription(enabled: Boolean) {
|
||||||
preferencesRepository.setShowTranscription(enabled)
|
preferencesRepository.setShowTranscription(enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user