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