diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/PlayerActivity.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/PlayerActivity.kt
index 524911a8..47d6350b 100644
--- a/app/src/main/java/cc/wordview/app/ui/activities/player/PlayerActivity.kt
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/PlayerActivity.kt
@@ -36,14 +36,14 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cc.wordview.app.components.extensions.setOrientationSensorLandscape
import cc.wordview.app.components.ui.CircularProgressIndicator
-import cc.wordview.app.extractor.VideoStream
import cc.wordview.app.misc.AppSettings
import cc.wordview.app.ui.activities.WordViewActivity
import cc.wordview.app.ui.activities.player.composables.ErrorScreen
import cc.wordview.app.ui.activities.player.composables.Player
-import cc.wordview.app.ui.activities.player.viewmodel.PlayerState
+import cc.wordview.app.ui.activities.player.viewmodel.LoadState
import cc.wordview.app.ui.activities.player.viewmodel.PlayerViewModel
import cc.wordview.app.components.ui.OneTimeEffect
+import cc.wordview.app.ui.activities.player.viewmodel.PlayerErrorState
import cc.wordview.app.ui.theme.WordViewTheme
import cc.wordview.gengolex.Language
import dagger.hilt.android.AndroidEntryPoint
@@ -68,10 +68,7 @@ class PlayerActivity : WordViewActivity() {
enableEdgeToEdge()
setContent {
ProvidePreferenceLocals {
- val state by viewModel.playerState.collectAsStateWithLifecycle()
- val videoStream by viewModel.videoStream.collectAsStateWithLifecycle()
- val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle()
- val statusCode by viewModel.statusCode.collectAsStateWithLifecycle()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val langTag = AppSettings.language.get()
@@ -84,14 +81,13 @@ class PlayerActivity : WordViewActivity() {
CoroutineScope(Dispatchers.IO).launch {
try {
- viewModel.videoStream.value.init(videoId, context)
+ uiState.videoStream.init(videoId, context)
- viewModel.initAudio(videoStream.getStreamURL())
- viewModel.getLyrics(videoId, lang, videoStream)
+ viewModel.initAudio(uiState.videoStream.getStreamURL())
+ viewModel.getLyrics(videoId, lang, uiState.videoStream)
} catch (e: ExtractionException) {
Timber.e(e)
- viewModel.setErrorMessage(e.message.toString())
- viewModel.setPlayerState(PlayerState.ERROR)
+ viewModel.declarePlayerError(PlayerErrorState(e.message.toString()))
}
}
}
@@ -100,16 +96,16 @@ class PlayerActivity : WordViewActivity() {
WordViewTheme(darkTheme = true) {
Scaffold { innerPadding ->
- when (state) {
- PlayerState.READY -> Player(videoId, viewModel, innerPadding)
+ when (uiState.loadState) {
+ LoadState.READY -> Player(videoId, viewModel, innerPadding)
- PlayerState.ERROR -> ErrorScreen(errorMessage, viewModel, {
+ LoadState.ERROR -> ErrorScreen(viewModel) {
Timber.d("Refreshing player")
- viewModel.setPlayerState(PlayerState.LOADING)
+ viewModel.setLoadState(LoadState.LOADING)
start()
- }, statusCode)
+ }
- PlayerState.LOADING -> Box(
+ LoadState.LOADING -> Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
@@ -125,10 +121,10 @@ class PlayerActivity : WordViewActivity() {
override fun onPause() {
super.onPause()
- val playerState = viewModel.playerState.value
+ val playerState = viewModel.uiState.value.loadState
- if (playerState == PlayerState.READY) {
- val player = viewModel.player.value
+ if (playerState == LoadState.READY) {
+ val player = viewModel.uiState.value.player
player.pause()
}
}
@@ -140,7 +136,7 @@ class PlayerActivity : WordViewActivity() {
override fun onDestroy() {
if (isFinishing) {
- viewModel.setVideoStream(VideoStream())
+ viewModel.cleanup()
}
super.onDestroy()
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/composables/ErrorScreen.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/composables/ErrorScreen.kt
index fb72a5e9..1f0076f9 100644
--- a/app/src/main/java/cc/wordview/app/ui/activities/player/composables/ErrorScreen.kt
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/composables/ErrorScreen.kt
@@ -42,8 +42,9 @@ import cc.wordview.app.ui.activities.player.viewmodel.PlayerViewModel
import cc.wordview.app.ui.theme.Typography
@Composable
-fun ErrorScreen(message: String, viewModel: PlayerViewModel, refresh: () -> Unit, statusCode: Int) {
- val videoStream by viewModel.videoStream.collectAsStateWithLifecycle()
+fun ErrorScreen(viewModel: PlayerViewModel, refresh: () -> Unit) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val errorState by viewModel.errorState.collectAsStateWithLifecycle()
val activity = LocalActivity.current!!
Column(
@@ -55,7 +56,7 @@ fun ErrorScreen(message: String, viewModel: PlayerViewModel, refresh: () -> Unit
) {
Image(
modifier = Modifier.size(180.dp),
- painter = if (statusCode == 404) painterResource(id = R.drawable.nolyrics) else painterResource(id = R.drawable.radio),
+ painter = if (errorState.code == 404) painterResource(id = R.drawable.nolyrics) else painterResource(id = R.drawable.radio),
contentDescription = null
)
Spacer(Modifier.size(8.dp))
@@ -66,10 +67,10 @@ fun ErrorScreen(message: String, viewModel: PlayerViewModel, refresh: () -> Unit
fontWeight = FontWeight.SemiBold,
)
Text(
- text = if (statusCode == 404) stringResource(
+ text = if (errorState.code == 404) stringResource(
R.string.couldn_t_find_any_lyrics_for,
- videoStream.info.name
- ) else message,
+ uiState.videoStream.info.name
+ ) else errorState.message,
textAlign = TextAlign.Center,
style = Typography.bodySmall,
fontWeight = FontWeight.Light,
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/composables/Player.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/composables/Player.kt
index 3081314e..c94c487e 100644
--- a/app/src/main/java/cc/wordview/app/ui/activities/player/composables/Player.kt
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/composables/Player.kt
@@ -48,7 +48,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.LayoutDirection
@@ -72,40 +71,37 @@ import cc.wordview.app.ui.components.TextCue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Player(videoId: String, viewModel: PlayerViewModel, innerPadding: PaddingValues) {
- val player by viewModel.player.collectAsStateWithLifecycle()
val currentCue by viewModel.currentCue.collectAsStateWithLifecycle()
- val playIcon by viewModel.playIcon.collectAsStateWithLifecycle()
- val finalized by viewModel.finalized.collectAsStateWithLifecycle()
val isBuffering by viewModel.isBuffering.collectAsStateWithLifecycle()
val currentPosition by viewModel.currentPosition.collectAsStateWithLifecycle()
val bufferedPercentage by viewModel.bufferedPercentage.collectAsStateWithLifecycle()
- val videoStream by viewModel.videoStream.collectAsStateWithLifecycle()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val activity = LocalActivity.current!!
val density = LocalDensity.current
val composerMode = AppSettings.composerMode.get()
- LaunchedEffect(finalized) {
- if (finalized) {
- player.stop()
+ LaunchedEffect(uiState.finalized) {
+ if (uiState.finalized) {
+ uiState.player.stop()
}
}
fun back() {
- player.stop()
+ uiState.player.stop()
activity.finish()
}
BackHandler { back() }
- OneTimeEffect { player.togglePlay() }
+ OneTimeEffect { uiState.player.togglePlay() }
Box(
modifier = Modifier
.fillMaxSize()
.testTag("interface")
) {
- FadeInAsyncImage(videoStream.getHQThumbnail())
+ FadeInAsyncImage(uiState.videoStream.getHQThumbnail())
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -156,11 +152,11 @@ fun Player(videoId: String, viewModel: PlayerViewModel, innerPadding: PaddingVal
}
Column {
Text(
- text = videoStream.info.name,
+ text = uiState.videoStream.info.name,
fontSize = 18.sp
)
Text(
- text = videoStream.info.getCleanUploaderName(),
+ text = uiState.videoStream.info.getCleanUploaderName(),
fontSize = 12.sp
)
}
@@ -173,7 +169,7 @@ fun Player(videoId: String, viewModel: PlayerViewModel, innerPadding: PaddingVal
.padding(end = WindowInsets.displayCutout.getRight(density, LayoutDirection.Ltr).dp / 2),
displayAdvancedInformation = composerMode,
currentPosition = currentPosition,
- duration = player.getDuration(),
+ duration = uiState.player.getDuration(),
videoId = videoId,
bufferingProgress = bufferedPercentage
)
@@ -190,21 +186,21 @@ fun Player(videoId: String, viewModel: PlayerViewModel, innerPadding: PaddingVal
modifier = Modifier.testTag("skip-back"),
icon = Icons.Filled.SkipPrevious,
size = 72.dp,
- onClick = { player.skipBack() }
+ onClick = { uiState.player.skipBack() }
)
CrossfadeIconButton(
modifier = Modifier
.testTag("toggle-play")
.alpha(if (isBuffering) 0.0f else 1.0f),
- icon = playIcon,
+ icon = uiState.playIcon,
size = 80.dp,
- onClick = { player.togglePlay() }
+ onClick = { uiState.player.togglePlay() }
)
CrossfadeIconButton(
modifier = Modifier.testTag("skip-forward"),
icon = Icons.Filled.SkipNext,
size = 72.dp,
- onClick = { player.skipForward() }
+ onClick = { uiState.player.skipForward() }
)
}
}
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerState.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/LoadState.kt
similarity index 96%
rename from app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerState.kt
rename to app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/LoadState.kt
index 19b57915..a16a5e3f 100644
--- a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerState.kt
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/LoadState.kt
@@ -17,6 +17,6 @@
package cc.wordview.app.ui.activities.player.viewmodel
-enum class PlayerState {
+enum class LoadState {
ERROR, LOADING, READY
}
\ No newline at end of file
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerErrorState.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerErrorState.kt
new file mode 100644
index 00000000..636e3320
--- /dev/null
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerErrorState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 Arthur Araujo
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package cc.wordview.app.ui.activities.player.viewmodel
+
+/**
+ * Contains information about an error the player has encountered
+ */
+data class PlayerErrorState(
+ val message: String = "",
+ val code: Int = 0,
+)
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerUIState.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerUIState.kt
new file mode 100644
index 00000000..5f36578f
--- /dev/null
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerUIState.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 Arthur Araujo
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package cc.wordview.app.ui.activities.player.viewmodel
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.ui.graphics.vector.ImageVector
+import cc.wordview.app.components.media.AudioPlayer
+import cc.wordview.app.components.media.caption.Lyrics
+import cc.wordview.app.extractor.VideoStream
+import cc.wordview.gengolex.Language
+import cc.wordview.gengolex.Parser
+
+/**
+ * The interface states of player, these are not updated constantly so separating
+ * them into a single class does not offer a noticeable performance prejudice.
+ */
+data class PlayerUIState(
+ val playIcon: ImageVector = Icons.Filled.PlayArrow,
+ val lyrics: Lyrics = Lyrics("", Parser(Language.ENGLISH)),
+ val player: AudioPlayer = AudioPlayer(),
+ val loadState: LoadState = LoadState.LOADING,
+ val finalized: Boolean = false,
+ val videoStream: VideoStream = VideoStream(),
+)
diff --git a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerViewModel.kt
index 74965109..16169f77 100644
--- a/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerViewModel.kt
+++ b/app/src/main/java/cc/wordview/app/ui/activities/player/viewmodel/PlayerViewModel.kt
@@ -24,11 +24,9 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cc.wordview.app.BuildConfig
-import cc.wordview.app.components.media.AudioPlayer
import cc.wordview.app.components.media.AudioPlayerListener
import cc.wordview.app.database.RoomAccess
import cc.wordview.app.components.extensions.toSeconds
-import cc.wordview.app.extractor.VideoStream
import cc.wordview.app.extractor.VideoStreamInterface
import cc.wordview.app.components.media.caption.Lyrics
import cc.wordview.app.components.media.caption.WordViewCue
@@ -53,46 +51,35 @@ class PlayerViewModel @Inject constructor(
private val playerRepository: PlayerRepository,
@ApplicationContext private val appContext: Context
) : ViewModel() {
- private val _playIcon = MutableStateFlow(Icons.Filled.PlayArrow)
- private val _lyrics = MutableStateFlow(Lyrics("", Parser(Language.ENGLISH)))
- private val _parser = MutableStateFlow(Parser(Language.ENGLISH))
- private val _player = MutableStateFlow(AudioPlayer())
- private val _currentCue = MutableStateFlow(WordViewCue())
- private val _playerState = MutableStateFlow(PlayerState.LOADING)
- private val _finalized = MutableStateFlow(false)
- private val _isBuffering = MutableStateFlow(false)
- private val _errorMessage = MutableStateFlow("")
- private val _statusCode = MutableStateFlow(0)
+ private val _uiState = MutableStateFlow(PlayerUIState())
+ private val _errorState = MutableStateFlow(PlayerErrorState())
- private val _videoStream = MutableStateFlow(VideoStream())
+ val uiState = _uiState.asStateFlow()
+ val errorState = _errorState.asStateFlow()
- // Seekbar states
+ private val _currentCue = MutableStateFlow(WordViewCue())
private val _currentPosition = MutableStateFlow(0L)
private val _bufferedPercentage = MutableStateFlow(0)
+ private val _isBuffering = MutableStateFlow(false)
- val currentPosition = _currentPosition.asStateFlow()
- val bufferedPercentage = _bufferedPercentage.asStateFlow()
- val playIcon = _playIcon.asStateFlow()
- val player = _player.asStateFlow()
val currentCue = _currentCue.asStateFlow()
- val playerState = _playerState.asStateFlow()
- val finalized = _finalized.asStateFlow()
+ val currentPosition = _currentPosition.asStateFlow()
+ val bufferedPercentage = _bufferedPercentage.asStateFlow()
val isBuffering = _isBuffering.asStateFlow()
- val errorMessage = _errorMessage.asStateFlow()
- val statusCode = _statusCode.asStateFlow()
- val videoStream = _videoStream.asStateFlow()
+
+
+ private var parser = Parser(Language.ENGLISH)
private val viewedVideoDao = RoomAccess.getDatabase().viewedVideoDao()
- // tracks the steps to consider that the player is
- // prepared to start playing (audio ready, lyrics ready, dictionary ready)
- private val stepsReady = MutableStateFlow(0)
+ var lyricsReady: Boolean = false
+ var playerReady: Boolean = false
+ var imagesReady: Boolean = false
- private fun computeAndCheckReady() {
- stepsReady.update { it + 1 }
- if (stepsReady.value == 3)
- setPlayerState(PlayerState.READY)
+ private fun checkReady() {
+ if (lyricsReady && playerReady && imagesReady)
+ setLoadState(LoadState.READY)
}
fun getLyrics(
@@ -101,16 +88,16 @@ class PlayerViewModel @Inject constructor(
video: VideoStreamInterface
) = viewModelScope.launch {
playerRepository.onFail = { message, status ->
- _errorMessage.update { message }
- _statusCode.update { status }
- setPlayerState(PlayerState.ERROR)
+ declarePlayerError(PlayerErrorState(message, status))
}
playerRepository.onSucceed = { lyrics, dictionary ->
- initParser(lang)
- addDictionary(lang.dictionaryName, dictionary)
+ parser = Parser(lang)
+ parser.addDictionary(lang.dictionaryName, dictionary)
parseLyrics(lyrics)
- computeAndCheckReady()
+
+ lyricsReady = true
+ checkReady()
preloadImages()
}
@@ -119,20 +106,23 @@ class PlayerViewModel @Inject constructor(
}
private fun preloadImages() {
- for (cue in _lyrics.value) {
+ for (cue in _uiState.value.lyrics) {
for (word in cue.words) {
enqueueImage(word.parent)
}
}
CoroutineScope(Dispatchers.IO).launch {
- ImageCacheManager.onQueueCompleted = { computeAndCheckReady() }
+ ImageCacheManager.onQueueCompleted = {
+ imagesReady = true
+ checkReady()
+ }
ImageCacheManager.executeAllInQueue()
}
}
- private fun enqueueImage(parent: String) = viewModelScope.launch(Dispatchers.IO) {
- if (parent == "") return@launch
+ private fun enqueueImage(parent: String) {
+ if (parent == "") return
val request = ImageRequest.Builder(appContext)
.data("${BuildConfig.API_BASE_URL}/api/v1/image?parent=$parent")
@@ -155,18 +145,21 @@ class PlayerViewModel @Inject constructor(
}
onPlaybackEnd = {
- player.value.stop()
+ _uiState.value.player.stop()
}
}
- player.value.apply {
+ _uiState.value.player.apply {
onPositionChange = { pos, bufferedPercentage ->
- setCurrentCue(_lyrics.value.getCueAt(pos))
+ setCurrentCue(_uiState.value.lyrics.getCueAt(pos))
_currentPosition.update { pos.toLong() }
_bufferedPercentage.update { bufferedPercentage }
}
- onInitializeFail = { setPlayerState(PlayerState.ERROR) }
- onPrepared = { computeAndCheckReady() }
+ onInitializeFail = { setLoadState(LoadState.ERROR) }
+ onPrepared = {
+ playerReady = true
+ checkReady()
+ }
initialize(videoStreamUrl, appContext, listener)
}
@@ -189,38 +182,37 @@ class PlayerViewModel @Inject constructor(
}
private fun playIconPause() {
- _playIcon.update { Icons.Filled.PlayArrow }
+ _uiState.update { it.copy(playIcon = Icons.Filled.PlayArrow) }
}
private fun playIconPlay() {
- _playIcon.update { Icons.Filled.Pause }
+ _uiState.update { it.copy(playIcon = Icons.Filled.Pause) }
}
private fun parseLyrics(lyrics: String) {
- _lyrics.update { Lyrics(lyrics, _parser.value) }
- }
-
- private fun initParser(language: Language) {
- _parser.update { Parser(language) }
- }
-
- private fun addDictionary(name: String, dictionary: String) {
- _parser.value.addDictionary(name, dictionary)
+ _uiState.update { it.copy(lyrics = Lyrics(lyrics, parser)) }
}
private fun setCurrentCue(cue: WordViewCue) {
_currentCue.update { cue }
}
- fun setPlayerState(playerState: PlayerState) {
- _playerState.update { playerState }
+ fun setLoadState(loadState: LoadState) {
+ _uiState.update { it.copy(loadState = loadState) }
}
- fun setErrorMessage(message: String) {
- _errorMessage.update { message }
+ /**
+ * Declares the error that has happened and directions the player to show it
+ */
+ fun declarePlayerError(errorState: PlayerErrorState) {
+ _errorState.update { errorState }
+ _uiState.update { it.copy(loadState = LoadState.ERROR) }
}
- fun setVideoStream(videoStream: VideoStreamInterface) {
- _videoStream.update { videoStream }
+ /**
+ * Performs session cleanups
+ */
+ fun cleanup() {
+ _uiState.update { PlayerUIState() }
}
}
\ No newline at end of file