Fix playback progress updates on exit

This commit is contained in:
Balogh Barnabás
2026-03-24 20:54:07 +01:00
parent ada0e9600a
commit 2e8c864522
5 changed files with 44 additions and 33 deletions

View File

@@ -15,8 +15,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -80,7 +81,8 @@ private fun SeriesScreenInternal(
} }
return series.seasons.first() return series.seasons.first()
} }
val selectedSeason = remember { mutableStateOf<Season>(getDefaultSeason()) } var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) }
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = remember(series) { val nextUpEpisode = remember(series) {
series.seasons.firstNotNullOfOrNull { season -> series.seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched } season.episodes.firstOrNull { !it.watched }
@@ -89,8 +91,8 @@ private fun SeriesScreenInternal(
val seriesDownloadState by viewModel.seriesDownloadState.collectAsState() val seriesDownloadState by viewModel.seriesDownloadState.collectAsState()
val seasonDownloadState by viewModel.seasonDownloadState.collectAsState() val seasonDownloadState by viewModel.seasonDownloadState.collectAsState()
LaunchedEffect(selectedSeason.value) { LaunchedEffect(selectedSeason.id, selectedSeason.episodes) {
viewModel.observeSeasonDownloadState(selectedSeason.value.episodes) viewModel.observeSeasonDownloadState(selectedSeason.episodes)
} }
Scaffold( Scaffold(
@@ -133,12 +135,12 @@ private fun SeriesScreenInternal(
SeriesActionButtons( SeriesActionButtons(
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
seriesDownloadState = seriesDownloadState, seriesDownloadState = seriesDownloadState,
selectedSeason = selectedSeason.value, selectedSeason = selectedSeason,
seasonDownloadState = seasonDownloadState, seasonDownloadState = seasonDownloadState,
onDownloadOptionSelected = { option -> onDownloadOptionSelected = { option ->
when (option) { when (option) {
SeriesDownloadOption.SEASON -> SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(selectedSeason.value.episodes) viewModel.downloadSeason(selectedSeason.episodes)
SeriesDownloadOption.SERIES -> SeriesDownloadOption.SERIES ->
viewModel.downloadSeries(series) viewModel.downloadSeries(series)
SeriesDownloadOption.SMART -> SeriesDownloadOption.SMART ->
@@ -157,11 +159,11 @@ private fun SeriesScreenInternal(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.seasons,
selectedSeason = selectedSeason.value, selectedSeason = selectedSeason,
onSelect = { selectedSeason.value = it } onSelect = { selectedSeasonId = it.id }
) )
EpisodeCarousel( EpisodeCarousel(
episodes = selectedSeason.value.episodes, episodes = selectedSeason.episodes,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if(series.cast.isNotEmpty()) { if(series.cast.isNotEmpty()) {

View File

@@ -235,6 +235,16 @@ class PlayerManager @Inject constructor(
_playbackState.update { it.copy(error = null) } _playbackState.update { it.copy(error = null) }
} }
fun snapshotProgress(): PlaybackProgressSnapshot {
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
return PlaybackProgressSnapshot(
durationMs = duration,
positionMs = player.currentPosition,
bufferedMs = player.bufferedPosition,
isLive = player.isCurrentMediaItemLive
)
}
private suspend fun applyTrackPreferences() { private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return
@@ -307,15 +317,7 @@ class PlayerManager @Inject constructor(
private fun startProgressLoop() { private fun startProgressLoop() {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs _progress.value = snapshotProgress()
val position = player.currentPosition
val buffered = player.bufferedPosition
_progress.value = PlaybackProgressSnapshot(
durationMs = duration,
positionMs = position,
bufferedMs = buffered,
isLive = player.isCurrentMediaItemLive
)
delay(500) delay(500)
} }
} }

View File

@@ -29,6 +29,11 @@ class ProgressManager @Inject constructor(
private var lastDurationMs: Long = 0L private var lastDurationMs: Long = 0L
private var isPaused: Boolean = false private var isPaused: Boolean = false
fun syncProgress(snapshot: PlaybackProgressSnapshot) {
lastPositionMs = snapshot.positionMs
lastDurationMs = snapshot.durationMs
}
fun bind( fun bind(
playbackState: StateFlow<PlaybackStateSnapshot>, playbackState: StateFlow<PlaybackStateSnapshot>,
progress: StateFlow<PlaybackProgressSnapshot>, progress: StateFlow<PlaybackProgressSnapshot>,

View File

@@ -251,6 +251,7 @@ class PlayerViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
autoHideJob?.cancel() autoHideJob?.cancel()
progressManager.syncProgress(playerManager.snapshotProgress())
progressManager.release() progressManager.release()
playerManager.release() playerManager.release()
} }

View File

@@ -10,8 +10,11 @@ import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.download.DownloadState
import hu.bbara.purefin.feature.download.MediaDownloadManager import hu.bbara.purefin.feature.download.MediaDownloadManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -23,8 +26,14 @@ class MovieScreenViewModel @Inject constructor(
private val mediaDownloadManager: MediaDownloadManager private val mediaDownloadManager: MediaDownloadManager
): ViewModel() { ): ViewModel() {
private val _movie = MutableStateFlow<Movie?>(null) private val _movieId = MutableStateFlow<UUID?>(null)
val movie = _movie.asStateFlow()
val movie: StateFlow<Movie?> = combine(
_movieId,
mediaRepository.movies
) { movieId, movies ->
movieId?.let { movies[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded) private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow() val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
@@ -34,7 +43,7 @@ class MovieScreenViewModel @Inject constructor(
} }
fun onPlay() { fun onPlay() {
val id = _movie.value?.id?.toString() ?: return val id = movie.value?.id?.toString() ?: return
navigationManager.navigate(Route.PlayerRoute(mediaId = id)) navigationManager.navigate(Route.PlayerRoute(mediaId = id))
} }
@@ -43,24 +52,16 @@ class MovieScreenViewModel @Inject constructor(
} }
fun selectMovie(movieId: UUID) { fun selectMovie(movieId: UUID) {
_movieId.value = movieId
viewModelScope.launch { viewModelScope.launch {
val movie = mediaRepository.movies.value[movieId]
if (movie == null) {
_movie.value = null
return@launch
}
_movie.value = movie
launch {
mediaDownloadManager.observeDownloadState(movieId.toString()).collect { mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
_downloadState.value = it _downloadState.value = it
} }
} }
} }
}
fun onDownloadClick() { fun onDownloadClick() {
val movieId = _movie.value?.id ?: return val movieId = movie.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {
when (_downloadState.value) { when (_downloadState.value) {
is DownloadState.NotDownloaded, is DownloadState.Failed -> { is DownloadState.NotDownloaded, is DownloadState.Failed -> {