Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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()))
}
}
}
Expand All @@ -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
) {
Expand All @@ -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()
}
}
Expand All @@ -140,7 +136,7 @@ class PlayerActivity : WordViewActivity() {

override fun onDestroy() {
if (isFinishing) {
viewModel.setVideoStream(VideoStream())
viewModel.cleanup()
}

super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
}
Expand All @@ -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
)
Expand All @@ -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() }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@

package cc.wordview.app.ui.activities.player.viewmodel

enum class PlayerState {
enum class LoadState {
ERROR, LOADING, READY
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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,
)
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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(),
)
Loading
Loading