Compare commits

...

10 Commits

Author SHA1 Message Date
53a7d4ad23 chore(build): increment app versionCode to 1000030 2026-06-20 19:42:19 +02:00
8ac0fd0bd1 feat(series): show season/episode context on play button and truncate subtitle
Enhance the series play button to display season and episode number alongside the play label when a next-up episode is available. Also constrain the subtitle text to a single line with ellipsis overflow to prevent layout issues from long subtitle strings.
2026-06-20 17:42:03 +00:00
1910a0c144 refactor(player): improve EmptyValueTimedVisibility and ValueChangeTimedVisibility components 2026-06-20 19:26:59 +02:00
217ba5b6d9 refactor(player): use mutableFloatStateOf for volume and brightness states 2026-06-20 18:38:59 +02:00
fee0e703eb chore(build): increment app versionCode to 1000029 2026-06-20 18:18:49 +02:00
0fc4644fdb refactor(tvplayer): remove unused isPlaying parameter from key event handler 2026-06-20 18:04:28 +02:00
6e48b35741 refactor(data/jellyfin): reload media items on user data change events 2026-06-20 16:01:22 +00:00
8d5948438e feat(data/jellyfin): sync watch progress in real time via WebSocket
Add JellyfinWebSocketService that subscribes to UserDataChangedMessage events and propagates watch progress changes to the local media repository.

The WebSocket subscription is gated by login status and network connectivity (matching other background work), runs with automatic retry on transient failures, and follows the lifecycle of the Jellyfin SDK's ApiClient for automatic reconnection.

Inject the service into JellyfinSessionBootstrapper to ensure Hilt instantiates the singleton at app startup.
2026-06-20 13:27:10 +00:00
8a2702a7cb refactor(core/series): improve data loading reliability with concurrent initialization 2026-06-20 12:18:29 +00:00
5a98f3d2e7 refactor(core): separate data loading from repository reads
Move implicit loading side effects out of getX methods into explicit loadX methods, making data flow predictable. Remove seriesTitle StateFlow in favor of direct seriesName access. Add proper error handling to repository load operations.
2026-06-20 09:27:25 +00:00
19 changed files with 291 additions and 100 deletions

View File

@@ -41,7 +41,6 @@ fun TvEpisodeScreen(
} }
val episode = viewModel.episode.collectAsStateWithLifecycle() val episode = viewModel.episode.collectAsStateWithLifecycle()
val seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
val selectedEpisode = episode.value val selectedEpisode = episode.value
if (selectedEpisode == null) { if (selectedEpisode == null) {
@@ -51,7 +50,7 @@ fun TvEpisodeScreen(
TvEpisodeScreenContent( TvEpisodeScreenContent(
episode = selectedEpisode, episode = selectedEpisode,
seriesTitle = seriesTitle.value, seriesTitle = selectedEpisode.seriesName,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
{ {
navigationManager.navigate( navigationManager.navigate(

View File

@@ -244,7 +244,6 @@ fun TvPlayerScreen(
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
val handled = handleTvPlayerRootKeyEvent( val handled = handleTvPlayerRootKeyEvent(
event = event, event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible, controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay, popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls, onShowControls = ::showControls,
@@ -467,7 +466,6 @@ private fun HiddenTvSeekTimeline(
internal fun handleTvPlayerRootKeyEvent( internal fun handleTvPlayerRootKeyEvent(
event: KeyEvent, event: KeyEvent,
isPlaying: Boolean,
controlsVisible: Boolean, controlsVisible: Boolean,
popupVisible: Boolean, popupVisible: Boolean,
isPlaylistExpanded: Boolean, isPlaylistExpanded: Boolean,

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -18,13 +17,13 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text as TvText
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
@@ -35,6 +34,7 @@ import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable @Composable
fun TvSeriesScreen( fun TvSeriesScreen(
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
if (seriesData != null) { if (seriesData != null) {
TvSeriesScreenContent( TvSeriesScreenContent(
series = seriesData, series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
focusedSeasonId = focusedSeasonId, focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId, focusedEpisodeId = focusedEpisodeId,
modifier = modifier modifier = modifier
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
@Composable @Composable
internal fun TvSeriesScreenContent( internal fun TvSeriesScreenContent(
series: Series, series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
focusedSeasonId: UUID? = null, focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null, focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
initialFocusSeason.episodeCount > 0 initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason?.id) { LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } selectedSeason?.let { selectSeason(series.id, it.id) }
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(

View File

@@ -220,6 +220,8 @@ private fun ColumnScope.MediaDetailScaffoldContent(
color = scheme.onBackground, color = scheme.onBackground,
fontSize = uiModel.subtitleFontSize, fontSize = uiModel.subtitleFontSize,
fontWeight = uiModel.subtitleFontWeight, fontWeight = uiModel.subtitleFontWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier modifier = modifier
) )
} }

View File

@@ -25,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -86,8 +87,8 @@ fun PlayerScreen(
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) } var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
var showQueuePanel by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) } var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) } var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
@@ -191,30 +192,30 @@ fun PlayerScreen(
EmptyValueTimedVisibility( EmptyValueTimedVisibility(
value = horizontalSeekFeedback, value = horizontalSeekFeedback,
hideAfterMillis = 1_000 hideAfterMillis = 1_000,
modifier = Modifier
.align(Alignment.Center)
) { ) {
SeekAmountIndicator( SeekAmountIndicator(
deltaMs = it, deltaMs = it,
modifier = Modifier
.align(Alignment.Center)
) )
} }
EmptyValueTimedVisibility( EmptyValueTimedVisibility(
value = horizontalSeekPreviewPositionMs, value = horizontalSeekPreviewPositionMs,
hideAfterMillis = 1_000 hideAfterMillis = 1_000,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 24.dp, vertical = 28.dp)
) { previewPositionMs -> ) { previewPositionMs ->
if (controlsVisible) { if (!controlsVisible) {
HiddenSeekTimeline( HiddenSeekTimeline(
positionMs = previewPositionMs, positionMs = previewPositionMs,
durationMs = uiState.durationMs, durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs, bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters, chapterMarkers = uiState.chapters,
adMarkers = uiState.ads, adMarkers = uiState.ads,
isLive = uiState.isLive, isLive = uiState.isLive
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 24.dp, vertical = 28.dp)
) )
} }
} }

View File

@@ -99,6 +99,7 @@ fun SeriesScreen(
SeriesScreenInternal( SeriesScreenInternal(
series = seriesData, series = seriesData,
selectSeason = viewModel::selectSeason,
seriesDownloadState = seriesDownloadState, seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState, seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled, isSmartDownloadEnabled = isSmartDownloadEnabled,
@@ -113,7 +114,6 @@ fun SeriesScreen(
} }
} }
}, },
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState, onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome, onBack = viewModel::onGoHome,
onMarkAsWatched = viewModel::markAsWatched, onMarkAsWatched = viewModel::markAsWatched,
@@ -129,11 +129,11 @@ fun SeriesScreen(
@Composable @Composable
private fun SeriesScreenInternal( private fun SeriesScreenInternal(
series: Series, series: Series,
selectSeason: (UUID, UUID) -> Unit,
seriesDownloadState: DownloadState, seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState, seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean, isSmartDownloadEnabled: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit, onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit, onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {}, onMarkAsWatched: (Boolean) -> Unit = {},
@@ -175,7 +175,7 @@ private fun SeriesScreenInternal(
} }
LaunchedEffect(series.id, selectedSeason?.id) { LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } selectedSeason?.let { selectSeason(series.id, it.id) }
} }
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) { LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
@@ -250,9 +250,14 @@ private fun Series.toMediaDetailScaffoldUiModel(
metadataItems = listOf(year, "$seasonCount Seasons"), metadataItems = listOf(year, "$seasonCount Seasons"),
actions = selectedSeason?.let { actions = selectedSeason?.let {
val seriesWatched = unwatchedEpisodeCount == 0 val seriesWatched = unwatchedEpisodeCount == 0
val playButtonText = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched)
MediaDetailActionsUiModel( MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel( primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched), text = if (nextUpEpisode != null) {
"$playButtonText S${it.index} • E${nextUpEpisode.index}"
} else {
playButtonText
},
progress = mediaPlaybackProgress(nextUpEpisode?.progress), progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = onPlayClick, onClick = onPlayClick,
testTag = SeriesPlayButtonTag testTag = SeriesPlayButtonTag

View File

@@ -8,29 +8,37 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun <T> EmptyValueTimedVisibility( fun <T> EmptyValueTimedVisibility(
value: T?, value: T?,
hideAfterMillis: Long = 1_000, hideAfterMillis: Long = 1_000,
enterTransition: EnterTransition = EnterTransition.None,
exitTransition: ExitTransition = ExitTransition.None,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable (T) -> Unit content: @Composable (T) -> Unit
) { ) {
val shownValue = remember { mutableStateOf<T?>(null) } var displayedValue by remember { mutableStateOf(value) }
LaunchedEffect(value) { LaunchedEffect(value) {
if (value == null) { if (value != null) {
delay(hideAfterMillis) displayedValue = value
shownValue.value = null
} }
shownValue.value = value
} }
if (displayedValue != null) {
shownValue.value?.let { ValueChangeTimedVisibility(
content(it) value = displayedValue!!,
hideAfterMillis = hideAfterMillis,
enterTransition = enterTransition,
exitTransition = exitTransition,
modifier = modifier,
content = content
)
} }
} }
@@ -38,30 +46,40 @@ fun <T> EmptyValueTimedVisibility(
fun <T> ValueChangeTimedVisibility( fun <T> ValueChangeTimedVisibility(
value: T, value: T,
hideAfterMillis: Long = 1_000, hideAfterMillis: Long = 1_000,
enterTransition: EnterTransition = EnterTransition.None,
exitTransition: ExitTransition = ExitTransition.None,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable (T) -> Unit content: @Composable (T) -> Unit
) { ) {
var displayedValue by remember { mutableStateOf(value) } var displayedValue by remember { mutableStateOf(value) }
var isVisible by remember { mutableStateOf(false) }
var hasInitialValue by remember { mutableStateOf(false) } var hasInitialValue by remember { mutableStateOf(false) }
var isVisible by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var hideJob: Job? by remember { mutableStateOf(null) }
fun restartHideJob() {
hideJob?.cancel()
hideJob = coroutineScope.launch {
delay(hideAfterMillis)
isVisible = false
}
}
LaunchedEffect(value) { LaunchedEffect(value) {
displayedValue = value
if (!hasInitialValue) { if (!hasInitialValue) {
hasInitialValue = true hasInitialValue = true
return@LaunchedEffect return@LaunchedEffect
} }
displayedValue = value
isVisible = true isVisible = true
delay(hideAfterMillis) restartHideJob()
isVisible = false
} }
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
modifier = modifier, modifier = modifier,
enter = EnterTransition.None, enter = enterTransition,
exit = ExitTransition.None exit = exitTransition
) { ) {
content(displayedValue) content(displayedValue)
} }

View File

@@ -67,6 +67,27 @@ class CompositeLocalMediaRepository @Inject constructor(
) )
} }
override suspend fun loadMovie(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadMovie(id) },
offlineAction = { offlineRepository.loadMovie(id) },
)
}
override suspend fun loadSeries(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeries(id) },
offlineAction = { offlineRepository.loadSeries(id) },
)
}
override suspend fun loadEpisode(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadEpisode(id) },
offlineAction = { offlineRepository.loadEpisode(id) },
)
}
override suspend fun loadSeasons(seriesId: UUID) { override suspend fun loadSeasons(seriesId: UUID) {
runOnlineOrOfflineNoOp( runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasons(seriesId) }, onlineAction = { onlineRepository.loadSeasons(seriesId) },

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.core.data package hu.bbara.purefin.core.data
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Media
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -11,9 +12,20 @@ interface LocalMediaRepository : MediaMetadataUpdater {
val movies: StateFlow<Map<UUID, Movie>> val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>> val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>> val episodes: StateFlow<Map<UUID, Episode>>
suspend fun getTypeById(id: UUID): Media? {
return when (val media = movies.value[id] ?: series.value[id] ?: episodes.value[id]) {
is Movie -> Media.MovieMedia(movieId = media.id)
is Series -> Media.SeriesMedia(seriesId = media.id)
is Episode -> Media.EpisodeMedia(seriesId = media.seriesId, episodeId = media.id)
else -> null
}
}
suspend fun getMovie(id: UUID): Flow<Movie?> suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?> suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?> suspend fun getEpisode(id: UUID): Flow<Episode?>
suspend fun loadMovie(id: UUID)
suspend fun loadSeries(id: UUID)
suspend fun loadEpisode(id: UUID)
suspend fun loadSeasons(seriesId: UUID) suspend fun loadSeasons(seriesId: UUID)
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
} }

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -47,17 +46,6 @@ class EpisodeScreenViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val seriesTitle: StateFlow<String?> = _episode
.flatMapLatest { episode ->
if (episode == null) {
flowOf(null)
} else {
mediaCatalogReader(episode.offline).getSeries(episode.seriesId).map { it?.name }
}
}
.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()
@@ -76,6 +64,9 @@ class EpisodeScreenViewModel @Inject constructor(
fun selectEpisode(episode: EpisodeDto) { fun selectEpisode(episode: EpisodeDto) {
_episode.value = episode _episode.value = episode
viewModelScope.launch {
mediaCatalogReader(episode.offline).loadEpisode(episode.id)
}
viewModelScope.launch { viewModelScope.launch {
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect { mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
_downloadState.value = it _downloadState.value = it

View File

@@ -63,6 +63,9 @@ class MovieScreenViewModel @Inject constructor(
fun selectMovie(movie: MovieDto) { fun selectMovie(movie: MovieDto) {
_movie.value = movie _movie.value = movie
viewModelScope.launch {
mediaCatalogReader(movie.offline).loadMovie(movie.id)
}
viewModelScope.launch { viewModelScope.launch {
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect { mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
_downloadState.value = it _downloadState.value = it

View File

@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -98,12 +98,6 @@ class SeriesViewModel @Inject constructor(
} }
} }
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
}
}
fun downloadSeason(seriesId: UUID, seasonId: UUID) { fun downloadSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader() val mediaCatalogReader = selectedMediaCatalogReader()
@@ -195,7 +189,15 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(series: SeriesDto) { fun selectSeries(series: SeriesDto) {
_series.value = series _series.value = series
viewModelScope.launch { viewModelScope.launch {
mediaCatalogReader(series.offline).loadSeasons(series.id) val mediaCatalogReader = mediaCatalogReader(series.offline)
launch { mediaCatalogReader.loadSeries(series.id) }
launch { mediaCatalogReader.loadSeasons(series.id) }
}
}
fun selectSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
} }
} }

View File

@@ -169,8 +169,7 @@ class PlayerViewModel @Inject constructor(
_uiState.update { it.copy(error = dataErrorMessage) } _uiState.update { it.copy(error = dataErrorMessage) }
return return
} }
//TODO hack to preload the series media mediaCatalogReader.loadEpisode(UUID.fromString(id))
mediaCatalogReader.getEpisode(UUID.fromString(id))
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
playerManager.play(uuid) playerManager.play(uuid)
@@ -243,6 +242,7 @@ class PlayerViewModel @Inject constructor(
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? { private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
return when (this) { return when (this) {
is PlayableMedia.Movie -> { is PlayableMedia.Movie -> {
mediaCatalogReader.loadMovie(id)
val movie = mediaCatalogReader.getMovie(id).first() val movie = mediaCatalogReader.getMovie(id).first()
if (movie == null) { if (movie == null) {
Timber.tag(TAG).e("Movie not found for playlist item: $id") Timber.tag(TAG).e("Movie not found for playlist item: $id")
@@ -261,6 +261,7 @@ class PlayerViewModel @Inject constructor(
} }
is PlayableMedia.Series -> { is PlayableMedia.Series -> {
mediaCatalogReader.loadSeries(id)
val series = mediaCatalogReader.getSeries(id).first() val series = mediaCatalogReader.getSeries(id).first()
if (series == null) { if (series == null) {
Timber.tag(TAG).e("Series not found for playlist item: $id") Timber.tag(TAG).e("Series not found for playlist item: $id")
@@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor(
} }
is PlayableMedia.Episode -> { is PlayableMedia.Episode -> {
mediaCatalogReader.loadEpisode(id)
val episode = mediaCatalogReader.getEpisode(id).first() val episode = mediaCatalogReader.getEpisode(id).first()
if (episode == null) { if (episode == null) {
Timber.tag(TAG).e("Episode not found for playlist item: $id") Timber.tag(TAG).e("Episode not found for playlist item: $id")

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@@ -48,48 +47,70 @@ class InMemoryLocalMediaRepository @Inject constructor(
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap()) private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow() override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
override suspend fun getMovie(id: UUID): Flow<Movie?> { override suspend fun getMovie(id: UUID): Flow<Movie?> =
if (!moviesState.value.containsKey(id)) { moviesState.map { it[id] }.distinctUntilChanged()
override suspend fun getSeries(id: UUID): Flow<Series?> =
seriesState.map { it[id] }.distinctUntilChanged()
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
episodesState.map { it[id] }.distinctUntilChanged()
override suspend fun loadMovie(id: UUID) {
if (moviesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.MOVIE) { if (item.type != BaseItemKind.MOVIE) {
Timber.tag(TAG).d("Item is not an movie: ${item.type}") Timber.tag(TAG).d("Item is not an movie: ${item.type}")
return flowOf(null) return
} }
val movie = item.toMovie(serverUrl.first()) val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) } moviesState.update { current -> current + (movie.id to movie) }
} }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load movie $id")
throw error
} }
return moviesState.map { it[id] }.distinctUntilChanged()
} }
override suspend fun getSeries(id: UUID): Flow<Series?> { override suspend fun loadSeries(id: UUID) {
if (!seriesState.value.containsKey(id)) { if (seriesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) { if (item.type != BaseItemKind.SERIES) {
Timber.tag(TAG).d("Item is not an series: ${item.type}") Timber.tag(TAG).d("Item is not an series: ${item.type}")
return flowOf(null) return
} }
val series = item.toSeries(serverUrl.first()) val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) } seriesState.update { current -> current + (series.id to series) }
} }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load series $id")
throw error
} }
return seriesState.map { it[id] }.distinctUntilChanged()
} }
override suspend fun getEpisode(id: UUID): Flow<Episode?> { override suspend fun loadEpisode(id: UUID) {
if (!episodesState.value.containsKey(id)) { if (episodesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) { if (item.type != BaseItemKind.EPISODE) {
Timber.tag(TAG).d("Item is not an episode: ${item.type}") Timber.tag(TAG).d("Item is not an episode: ${item.type}")
return flowOf(null) return
} }
val episode = item.toEpisode(serverUrl.first()) val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) } episodesState.update { current -> current + (episode.id to episode) }
} }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load episode $id")
throw error
} }
val episode = episodesState.value[id] ?: return flowOf(null)
preloadSeasonsForEpisode(episode.seriesId)
return episodesState.map { it[id] }.distinctUntilChanged()
} }
fun upsertMovies(movies: List<Movie>) { fun upsertMovies(movies: List<Movie>) {
@@ -115,39 +136,49 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
} }
private suspend fun preloadSeasonsForEpisode(seriesId: UUID) {
try {
loadSeasonsInternal(seriesId)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).w(error, "Unable to preload seasons for episode series $seriesId")
}
}
private suspend fun loadSeasonsInternal(seriesId: UUID) { private suspend fun loadSeasonsInternal(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
val updatedSeries = series.copy( // Await the parallel loadSeries to populate the series entry.
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } // Precondition: seriesId refers to a real series (selectSeries always uses a
) // SeriesDto from a real series list). If loadSeries returns without populating
seriesState.update { it + (updatedSeries.id to updatedSeries) } // state (null item / wrong type) this suspends until cancelled by structured
// concurrency when a sibling load* call fails.
seriesState.first { it.containsKey(seriesId) }
seriesState.update { current ->
val existing = current[seriesId] ?: return@update current
if (existing.seasons.isNotEmpty()) return@update current
current + (seriesId to existing.copy(seasons = seasons))
}
} }
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
loadSeasons(seriesId) // Fast path: season already cached with episodes or known empty — skip the fetch.
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
}
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") // Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
// seasonId is always a valid season from a real EpisodeDto in this path.
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
// Await the season to be present in state (immediate when pre-loaded; waits for
// the parallel loadSeasons otherwise).
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true }
// Re-check guard after await: a concurrent call may have filled episodes already.
val series = seriesState.value[seriesId] ?: return
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
if (season.episodes.isNotEmpty() || season.episodeCount == 0) { if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return return
} }
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current -> seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy( val updatedSeries = currentSeries.copy(

View File

@@ -46,6 +46,18 @@ class OfflineLocalMediaRepository @Inject constructor(
return episodes.map { it[id] } return episodes.map { it[id] }
} }
override suspend fun loadMovie(id: UUID) {
// Offline movie content is already persisted.
}
override suspend fun loadSeries(id: UUID) {
// Offline series content is already persisted.
}
override suspend fun loadEpisode(id: UUID) {
// Offline episode content is already persisted.
}
override suspend fun loadSeasons(seriesId: UUID) { override suspend fun loadSeasons(seriesId: UUID) {
// Offline series content is already emitted with its saved seasons. // Offline series content is already emitted with its saved seasons.
} }

View File

@@ -77,7 +77,7 @@ class JellyfinApiClient @Inject constructor(
apiClientFactory = okHttpFactory apiClientFactory = okHttpFactory
} }
private val api = jellyfin.createApi() val api = jellyfin.createApi()
private val defaultItemFields = private val defaultItemFields =
listOf( listOf(

View File

@@ -2,12 +2,16 @@ package hu.bbara.purefin.data.jellyfin.session
import hu.bbara.purefin.core.data.SessionBootstrapper import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.websocket.JellyfinWebSocketService
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class JellyfinSessionBootstrapper @Inject constructor( class JellyfinSessionBootstrapper @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
// Referenced to ensure Hilt instantiates the singleton at app startup.
// The service starts its own subscription in its init block.
private val webSocketService: JellyfinWebSocketService,
) : SessionBootstrapper { ) : SessionBootstrapper {
override suspend fun initialize() { override suspend fun initialize() {
jellyfinApiClient.configureFromSession() jellyfinApiClient.configureFromSession()

View File

@@ -0,0 +1,90 @@
package hu.bbara.purefin.data.jellyfin.websocket
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.UserDataChangedMessage
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinWebSocketService @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository,
private val networkMonitor: NetworkMonitor,
private val localMediaRepository: LocalMediaRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
scope.launch {
combine(
userSessionRepository.isLoggedIn,
networkMonitor.isOnline,
) { loggedIn, online -> loggedIn && online }
.distinctUntilChanged()
// collectLatest cancels the previous body on every new active
// value, so a logout / connectivity flap really tears down the
// in-flight subscription instead of leaving it running.
.collectLatest { active ->
if (!active) return@collectLatest
while (currentCoroutineContext().isActive) {
try {
listenForUserDataChanges()
} catch (e: CancellationException) {
throw e
} catch (error: Exception) {
Timber.tag(TAG).w(error, "UserDataChanged subscription ended, retrying")
}
if (!currentCoroutineContext().isActive) break
delay(5000L)
}
}
}
}
private suspend fun listenForUserDataChanges() {
jellyfinApiClient.api.webSocket
.subscribe<UserDataChangedMessage>()
.collect { message ->
val info = message.data ?: return@collect
for (entry in info.userDataList) {
val itemId = entry.itemId
localMediaRepository.getTypeById(itemId).let { media ->
when (media) {
is Media.MovieMedia -> {
localMediaRepository.loadMovie(media.id)
}
is Media.EpisodeMedia -> {
localMediaRepository.loadEpisode(media.id)
}
is Media.SeriesMedia -> {
localMediaRepository.loadSeries(media.id)
}
else -> {}
}
}
}
}
}
private companion object {
const val TAG = "JellyfinWebSocket"
}
}

View File

@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
android.builtInKotlin=false android.builtInKotlin=false
android.newDsl=false android.newDsl=false
purefinReleaseVersionCode=1000028 purefinReleaseVersionCode=1000030
purefinDebugVersionCode=1000003 purefinDebugVersionCode=1000003
purefinTvVersionCode=2000014 purefinTvVersionCode=2000014