mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
10 Commits
80f25c6dea
...
53a7d4ad23
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a7d4ad23 | |||
| 8ac0fd0bd1 | |||
| 1910a0c144 | |||
| 217ba5b6d9 | |||
| fee0e703eb | |||
| 0fc4644fdb | |||
| 6e48b35741 | |||
| 8d5948438e | |||
| 8a2702a7cb | |||
| 5a98f3d2e7 |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -220,6 +220,8 @@ private fun ColumnScope.MediaDetailScaffoldContent(
|
||||
color = scheme.onBackground,
|
||||
fontSize = uiModel.subtitleFontSize,
|
||||
fontWeight = uiModel.subtitleFontWeight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
apiClientFactory = okHttpFactory
|
||||
}
|
||||
|
||||
private val api = jellyfin.createApi()
|
||||
val api = jellyfin.createApi()
|
||||
|
||||
private val defaultItemFields =
|
||||
listOf(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user