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 seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
val selectedEpisode = episode.value
if (selectedEpisode == null) {
@@ -51,7 +50,7 @@ fun TvEpisodeScreen(
TvEpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = seriesTitle.value,
seriesTitle = selectedEpisode.seriesName,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
navigationManager.navigate(

View File

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

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
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.TvMediaDetailBodyBox
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.waiting.PurefinWaitingScreen
import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable
fun TvSeriesScreen(
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
if (seriesData != null) {
TvSeriesScreenContent(
series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode,
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId,
modifier = modifier
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
@Composable
internal fun TvSeriesScreenContent(
series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
selectedSeason?.let { selectSeason(series.id, it.id) }
}
TvMediaDetailScaffold(

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Media
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.flow.Flow
@@ -11,9 +12,20 @@ interface LocalMediaRepository : MediaMetadataUpdater {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
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 getSeries(id: UUID): Flow<Series?>
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 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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -47,17 +46,6 @@ class EpisodeScreenViewModel @Inject constructor(
}
.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)
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
@@ -76,6 +64,9 @@ class EpisodeScreenViewModel @Inject constructor(
fun selectEpisode(episode: EpisodeDto) {
_episode.value = episode
viewModelScope.launch {
mediaCatalogReader(episode.offline).loadEpisode(episode.id)
}
viewModelScope.launch {
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
_downloadState.value = it

View File

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

View File

@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline
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.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
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) {
viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader()
@@ -195,7 +189,15 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(series: SeriesDto) {
_series.value = series
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) }
return
}
//TODO hack to preload the series media
mediaCatalogReader.getEpisode(UUID.fromString(id))
mediaCatalogReader.loadEpisode(UUID.fromString(id))
viewModelScope.launch {
runCatching {
playerManager.play(uuid)
@@ -243,6 +242,7 @@ class PlayerViewModel @Inject constructor(
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
return when (this) {
is PlayableMedia.Movie -> {
mediaCatalogReader.loadMovie(id)
val movie = mediaCatalogReader.getMovie(id).first()
if (movie == null) {
Timber.tag(TAG).e("Movie not found for playlist item: $id")
@@ -261,6 +261,7 @@ class PlayerViewModel @Inject constructor(
}
is PlayableMedia.Series -> {
mediaCatalogReader.loadSeries(id)
val series = mediaCatalogReader.getSeries(id).first()
if (series == null) {
Timber.tag(TAG).e("Series not found for playlist item: $id")
@@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor(
}
is PlayableMedia.Episode -> {
mediaCatalogReader.loadEpisode(id)
val episode = mediaCatalogReader.getEpisode(id).first()
if (episode == null) {
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.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -48,48 +47,70 @@ class InMemoryLocalMediaRepository @Inject constructor(
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
override suspend fun getMovie(id: UUID): Flow<Movie?> {
if (!moviesState.value.containsKey(id)) {
override suspend fun getMovie(id: UUID): Flow<Movie?> =
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 ->
if (item.type != BaseItemKind.MOVIE) {
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
return flowOf(null)
return
}
val movie = item.toMovie(serverUrl.first())
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?> {
if (!seriesState.value.containsKey(id)) {
override suspend fun loadSeries(id: UUID) {
if (seriesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) {
Timber.tag(TAG).d("Item is not an series: ${item.type}")
return flowOf(null)
return
}
val series = item.toSeries(serverUrl.first())
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?> {
if (!episodesState.value.containsKey(id)) {
override suspend fun loadEpisode(id: UUID) {
if (episodesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) {
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
return flowOf(null)
return
}
val episode = item.toEpisode(serverUrl.first())
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>) {
@@ -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) {
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(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
// Await the parallel loadSeries to populate the series entry.
// Precondition: seriesId refers to a real series (selectSeries always uses a
// SeriesDto from a real series list). If loadSeries returns without populating
// 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) {
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
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return
}
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy(

View File

@@ -46,6 +46,18 @@ class OfflineLocalMediaRepository @Inject constructor(
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) {
// Offline series content is already emitted with its saved seasons.
}

View File

@@ -77,7 +77,7 @@ class JellyfinApiClient @Inject constructor(
apiClientFactory = okHttpFactory
}
private val api = jellyfin.createApi()
val api = jellyfin.createApi()
private val defaultItemFields =
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.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.websocket.JellyfinWebSocketService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinSessionBootstrapper @Inject constructor(
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 {
override suspend fun initialize() {
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.builtInKotlin=false
android.newDsl=false
purefinReleaseVersionCode=1000028
purefinReleaseVersionCode=1000030
purefinDebugVersionCode=1000003
purefinTvVersionCode=2000014