Compare commits

...

8 Commits

Author SHA1 Message Date
9906d473c5 refactor: reduce maxLines for improved text display in PlayerQueuePanel 2026-04-25 22:45:36 +02:00
1b8300ee1d refactor: streamline playback handling by removing redundant seekTo call 2026-04-25 22:43:51 +02:00
aa58cf3aff refactor: optimize playlist queue updates by combining flows for current playable media 2026-04-25 22:35:58 +02:00
81a3504606 refactor: update media retrieval to use Flow and improve error handling in PlaylistQueue loading 2026-04-25 22:31:39 +02:00
b15453ab01 refactor: convert PlayableMedia to sealed class with specific media types 2026-04-25 21:25:11 +02:00
f9eaf0cf84 refactor: update artwork URL construction in PlaylistElementUiModel 2026-04-25 20:46:14 +02:00
78a5f716c0 refactor: refine TV detail cast rows and player queue behavior
Temporarily hide cast rows on TV movie, episode, and series detail surfaces while preserving the surrounding section layout.

Simplify TV player queue rows by removing the secondary subtitle line so queued items scan more cleanly.

Increase the next-up queue fetch from 5 to 10 items and skip missing playlist episodes with an error log instead of throwing during queue mapping.
2026-04-25 18:43:14 +02:00
f7f9fc1058 refactor: centralize playable media state
Move playback-related media state into shared model types and make the player consume a single PlayableMedia object instead of separately loading MediaItems, track preferences, resume positions, and media segments.

This updates PlayableMediaRepository to return playable media bundles, seeds the player playlist with the current item, keeps next-up entries in the same model, restores resume/segment handling, and fixes current queue highlighting.

The change also moves track preference models into core-model, centralizes Jellyfin BaseItemDto conversion, removes placeholder movie audio/subtitle fields, bumps the offline Room schema, and updates mobile/TV queue UI code for the playlist model.

Verification:
./gradlew --no-daemon :core-model:compileDebugKotlin :core:compileDebugKotlin :data:compileDebugKotlin :app:compileDebugKotlin
2026-04-25 18:08:23 +02:00
40 changed files with 602 additions and 608 deletions

View File

@@ -51,8 +51,8 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":data"))
implementation(project(":core")) implementation(project(":core"))
implementation(project(":core-model"))
implementation(project(":core-ui")) implementation(project(":core-ui"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)

View File

@@ -40,7 +40,7 @@ import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.QueueItemUi import hu.bbara.purefin.player.model.PlaylistElementUiModel
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType import hu.bbara.purefin.player.model.TrackType
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
@@ -247,14 +247,14 @@ class TvPlayerControlsOverlayTest {
OverlayHost( OverlayHost(
uiState = samplePlayerState().copy( uiState = samplePlayerState().copy(
queue = listOf( queue = listOf(
QueueItemUi( PlaylistElementUiModel(
id = "first", id = "first",
title = "Episode 1", title = "Episode 1",
subtitle = "Fallback entry", subtitle = "Fallback entry",
artworkUrl = null, artworkUrl = null,
isCurrent = false isCurrent = false
), ),
QueueItemUi( PlaylistElementUiModel(
id = "second", id = "second",
title = "Episode 2", title = "Episode 2",
subtitle = "Later", subtitle = "Later",
@@ -871,21 +871,21 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState(
positionMs = 120_000, positionMs = 120_000,
bufferedMs = 240_000, bufferedMs = 240_000,
queue = listOf( queue = listOf(
QueueItemUi( PlaylistElementUiModel(
id = "played", id = "played",
title = "Already Played", title = "Already Played",
subtitle = "Episode 0", subtitle = "Episode 0",
artworkUrl = null, artworkUrl = null,
isCurrent = false isCurrent = false
), ),
QueueItemUi( PlaylistElementUiModel(
id = "current", id = "current",
title = "Currently Playing", title = "Currently Playing",
subtitle = "Episode 1", subtitle = "Episode 1",
artworkUrl = null, artworkUrl = null,
isCurrent = true isCurrent = true
), ),
QueueItemUi( PlaylistElementUiModel(
id = "next", id = "next",
title = "Episode 2", title = "Episode 2",
subtitle = "Up next", subtitle = "Up next",

View File

@@ -13,21 +13,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.media.MediaCastRow import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
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
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.image.ArtworkKind
@Composable @Composable
fun TvEpisodeScreen( fun TvEpisodeScreen(
@@ -126,7 +125,7 @@ internal fun TvEpisodeScreenContent(
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast") MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
MediaCastRow(cast = episode.cast) // MediaCastRow(cast = episode.cast)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
} }

View File

@@ -13,8 +13,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.media.MediaCastRow import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
@@ -22,9 +23,7 @@ import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
@Composable @Composable
fun TvMovieScreen( fun TvMovieScreen(
@@ -91,8 +90,8 @@ internal fun TvMovieScreenContent(
item(key = "movie-playback") { item(key = "movie-playback") {
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
MediaDetailPlaybackSection( MediaDetailPlaybackSection(
audioTrack = movie.audioTrack, audioTrack = "ENG",
subtitles = movie.subtitles, subtitles = "ENG",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
@@ -103,7 +102,7 @@ internal fun TvMovieScreenContent(
Column(modifier = it.fillMaxWidth()) { Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast") MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
MediaCastRow(cast = movie.cast) // MediaCastRow(cast = movie.cast)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
} }

View File

@@ -276,7 +276,7 @@ fun TvPlayerScreen(
onExpandPlaylist = expandPlaylist, onExpandPlaylist = expandPlaylist,
onCollapsePlaylist = collapsePlaylistToControls, onCollapsePlaylist = collapsePlaylistToControls,
onSelectQueueItem = { id -> onSelectQueueItem = { id ->
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS) viewModel.playQueueItem(id)
collapsePlaylistToControls() collapsePlaylistToControls()
}, },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),

View File

@@ -20,11 +20,11 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.withFrameNanos
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.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -41,9 +41,9 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.QueueItemUi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.PlaylistElementUiModel
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row" internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item" internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
@@ -159,7 +159,7 @@ internal fun TvPlayerQueuePanel(
@Composable @Composable
private fun TvQueueRowCard( private fun TvQueueRowCard(
item: QueueItemUi, item: PlaylistElementUiModel,
isCurrent: Boolean, isCurrent: Boolean,
isFirst: Boolean, isFirst: Boolean,
isLast: Boolean, isLast: Boolean,
@@ -231,15 +231,6 @@ private fun TvQueueRowCard(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
item.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Text(
text = subtitle,
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }

View File

@@ -10,12 +10,16 @@ import androidx.compose.runtime.collectAsState
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.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.feature.content.series.SeriesViewModel
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
@@ -26,10 +30,7 @@ import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs 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.navigation.SeriesDto import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.feature.content.series.SeriesViewModel
import java.util.UUID import java.util.UUID
@Composable @Composable

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -50,21 +51,20 @@ import androidx.compose.ui.text.style.TextOverflow
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 hu.bbara.purefin.ui.common.media.MediaCastRow import hu.bbara.purefin.feature.content.series.SeriesViewModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
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.feature.content.series.SeriesViewModel import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
internal const val SeriesPlayButtonTag = "series-play-button" internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab" internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@@ -367,13 +367,13 @@ private fun TvEpisodeCard(
@Composable @Composable
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) { internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
MediaCastRow( // MediaCastRow(
cast = cast, // cast = cast,
modifier = modifier, // modifier = modifier,
cardWidth = 84.dp, // cardWidth = 84.dp,
nameSize = 11.sp, // nameSize = 11.sp,
roleSize = 10.sp // roleSize = 10.sp
) // )
} }
private fun Episode.heroStatusText(): String { private fun Episode.heroStatusText(): String {

View File

@@ -30,19 +30,19 @@ import androidx.compose.ui.tooling.preview.Preview
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 hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.download.DownloadState import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.image.ArtworkKind
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -202,8 +202,6 @@ private fun previewMovie(): Movie =
format = "Dolby Vision", format = "Dolby Vision",
synopsis = "A new blade runner uncovers a buried secret that forces him to trace the vanished footsteps of Rick Deckard.", synopsis = "A new blade runner uncovers a buried secret that forces him to trace the vanished footsteps of Rick Deckard.",
imageUrlPrefix = "https://images.unsplash.com/photo-1519608487953-e999c86e7455", imageUrlPrefix = "https://images.unsplash.com/photo-1519608487953-e999c86e7455",
audioTrack = "English 5.1",
subtitles = "English CC",
cast = listOf( cast = listOf(
CastMember("Ryan Gosling", "K", null), CastMember("Ryan Gosling", "K", null),
CastMember("Ana de Armas", "Joi", null), CastMember("Ana de Armas", "Joi", null),

View File

@@ -125,8 +125,8 @@ internal fun MovieDetails(
MediaPlaybackSettings( MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface, backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface, foregroundColor = MaterialTheme.colorScheme.onSurface,
audioTrack = movie.audioTrack, audioTrack = "ENG",
subtitles = movie.subtitles subtitles = "ENG"
) )
if (movie.cast.isNotEmpty()) { if (movie.cast.isNotEmpty()) {

View File

@@ -1,5 +1,4 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -30,8 +29,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable @Composable
fun PlayerQueuePanel( fun PlayerQueuePanel(
@@ -83,7 +82,6 @@ fun PlayerQueuePanel(
items(uiState.queue, key = { item -> item.id }) { item -> items(uiState.queue, key = { item -> item.id }) { item ->
QueueRow( QueueRow(
title = item.title, title = item.title,
subtitle = item.subtitle,
artworkUrl = item.artworkUrl, artworkUrl = item.artworkUrl,
isCurrent = item.isCurrent, isCurrent = item.isCurrent,
onClick = { onSelect(item.id) } onClick = { onSelect(item.id) }
@@ -105,7 +103,7 @@ fun PlayerQueuePanel(
@Composable @Composable
private fun QueueRow( private fun QueueRow(
title: String, title: String,
subtitle: String?, subtitle: String? = null,
artworkUrl: String?, artworkUrl: String?,
isCurrent: Boolean, isCurrent: Boolean,
onClick: () -> Unit onClick: () -> Unit
@@ -147,7 +145,7 @@ private fun QueueRow(
color = scheme.onSurface, color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (subtitle != null) { if (subtitle != null) {

View File

@@ -1,13 +1,30 @@
plugins { plugins {
id("java-library") alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
} }
java {
android {
namespace = "hu.bbara.purefin.model"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
dependencies {
implementation(libs.media3.common)
implementation(libs.kotlinx.serialization.json)
}

View File

@@ -14,7 +14,5 @@ data class Movie(
val format: String, val format: String,
val synopsis: String, val synopsis: String,
val imageUrlPrefix: String, val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CastMember> val cast: List<CastMember>
) )

View File

@@ -0,0 +1,36 @@
package hu.bbara.purefin.model
import androidx.media3.common.MediaItem
import java.util.UUID
sealed class PlayableMedia {
abstract val id: UUID
abstract val resumePositionMs: Long
abstract val mediaItem: MediaItem
abstract val preferences: MediaTrackPreferences
abstract val mediaSegments: List<MediaSegment>
data class Movie(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
data class Series(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
data class Episode(
override val id: UUID,
override val resumePositionMs: Long,
override val mediaItem: MediaItem,
override val preferences: MediaTrackPreferences,
override val mediaSegments: List<MediaSegment>
) : PlayableMedia()
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.player.preference package hu.bbara.purefin.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@@ -43,6 +43,21 @@ class CompositeMediaRepository @Inject constructor(
.flatMapLatest { it.episodes } .flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository
.flatMapLatest { it.getMovie(id) }
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository
.flatMapLatest { it.getSeries(id) }
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return activeRepository
.flatMapLatest { it.getEpisode(id) }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) } return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
} }

View File

@@ -12,5 +12,8 @@ interface MediaCatalogReader {
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 getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
} }

View File

@@ -1,15 +1,13 @@
package hu.bbara.purefin.data package hu.bbara.purefin.data
import androidx.media3.common.MediaItem import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.MediaSegment
import java.util.UUID import java.util.UUID
interface PlayableMediaRepository { interface PlayableMediaRepository {
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia?
suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> suspend fun getNextUpPlayableMedias(
suspend fun getNextUpMediaItems(
episodeId: UUID, episodeId: UUID,
existingIds: Set<String>, existingIds: Set<UUID>,
count: Int = 9, count: Int,
): List<MediaItem> ): List<PlayableMedia>
} }

View File

@@ -9,17 +9,17 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.data.PlayableMediaRepository
import hu.bbara.purefin.data.PlaybackReportContext import hu.bbara.purefin.data.PlaybackReportContext
import hu.bbara.purefin.model.AudioTrackProperties
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.player.model.MediaContext import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SubtitleTrackProperties
import hu.bbara.purefin.player.model.MetadataState import hu.bbara.purefin.player.model.MetadataState
import hu.bbara.purefin.player.model.PlaybackProgressSnapshot import hu.bbara.purefin.player.model.PlaybackProgressSnapshot
import hu.bbara.purefin.player.model.PlaybackStateSnapshot import hu.bbara.purefin.player.model.PlaybackStateSnapshot
import hu.bbara.purefin.player.model.QueueItemUi
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType import hu.bbara.purefin.player.model.TrackType
import hu.bbara.purefin.player.preference.AudioTrackProperties
import hu.bbara.purefin.player.preference.SubtitleTrackProperties
import hu.bbara.purefin.player.preference.TrackMatcher import hu.bbara.purefin.player.preference.TrackMatcher
import hu.bbara.purefin.player.preference.TrackPreferencesRepository import hu.bbara.purefin.player.preference.TrackPreferencesRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -27,12 +27,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
@@ -44,6 +48,7 @@ import kotlin.math.abs
class PlayerManager @Inject constructor( class PlayerManager @Inject constructor(
val player: Player, val player: Player,
private val trackMapper: TrackMapper, private val trackMapper: TrackMapper,
private val playableMediaRepository: PlayableMediaRepository,
private val trackPreferencesRepository: TrackPreferencesRepository, private val trackPreferencesRepository: TrackPreferencesRepository,
private val trackMatcher: TrackMatcher private val trackMatcher: TrackMatcher
) { ) {
@@ -55,23 +60,28 @@ class PlayerManager @Inject constructor(
private val mediaSegmentManager = MediaSegmentManager(player as ExoPlayer) private val mediaSegmentManager = MediaSegmentManager(player as ExoPlayer)
private var currentMediaContext: MediaContext? = null private val _currentMediaId = MutableStateFlow<UUID?>(null)
private val _playlist = MutableStateFlow(emptyList<PlayableMedia>())
val playlist: StateFlow<List<PlayableMedia>> = _playlist.asStateFlow()
val currentPlayableMedia: Flow<PlayableMedia?> =
combine(_currentMediaId, _playlist) { mediaId, playlist ->
playlist.firstOrNull { it.id == mediaId }
}
private var pendingSeekPositionMs: Long? = null private var pendingSeekPositionMs: Long? = null
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow() val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
private val _progress = MutableStateFlow(PlaybackProgressSnapshot()) private val _progress = MutableStateFlow(PlaybackProgressSnapshot())
val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow() val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow()
private val _metadata = MutableStateFlow(MetadataState()) private val _metadata = MutableStateFlow(MetadataState())
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow() val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
private val _tracks = MutableStateFlow(TrackSelectionState()) private val _tracks = MutableStateFlow(TrackSelectionState())
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList()) val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
private val listener = object : Player.Listener { private val listener = object : Player.Listener {
override fun onPositionDiscontinuity( override fun onPositionDiscontinuity(
@@ -89,9 +99,18 @@ class PlayerManager @Inject constructor(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
clearPendingSeek() _currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
currentMediaContext?.let { scope.launch {
installMediaSegments(it.mediaSegments) val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value }
if (currentMedia == null) {
_playbackState.update { it.copy(error = "Media not found in playlist") }
return@launch
}
seekTo(currentMedia.resumePositionMs)
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
if (currentMedia is PlayableMedia.Episode) {
updatePlaylist()
}
} }
} }
@@ -111,16 +130,48 @@ class PlayerManager @Inject constructor(
startProgressLoop() startProgressLoop()
} }
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) { fun play(mediaId: UUID) {
currentMediaContext = mediaContext scope.launch {
clearPendingSeek() if (_playlist.value.map { it.id }.contains(mediaId)) {
player.setMediaItem(mediaItem) playFromPlaylist(mediaId)
player.prepare() } else {
player.playWhenReady = true playNewMedia(mediaId)
}
}
} }
fun addToQueue(mediaItem: MediaItem) { private fun playFromPlaylist(mediaId: UUID) {
player.addMediaItem(mediaItem) val index = _playlist.value.indexOfFirst { it.id == mediaId }
if (index != -1) {
player.seekToDefaultPosition(index)
} else {
_playbackState.update { it.copy(error = "Media not found in playlist") }
}
}
private suspend fun playNewMedia(mediaId: UUID) {
var playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
if (playableMedia == null) {
_playbackState.update { it.copy(error = "Media not found") }
return
}
_playlist.update { it + playableMedia }
player.setMediaItem(playableMedia.mediaItem)
player.prepare()
}
private suspend fun updatePlaylist() {
val nextUpPlayableMedias = playableMediaRepository.getNextUpPlayableMedias(
episodeId = _currentMediaId.value ?: return,
existingIds = _playlist.value.map { it.id }.toSet(),
count = 10
)
addToQueue(nextUpPlayableMedias)
}
private fun addToQueue(playableMedias: List<PlayableMedia>) {
_playlist.update { it + playableMedias }
player.addMediaItems(playableMedias.map { it.mediaItem })
} }
fun togglePlayPause() { fun togglePlayPause() {
@@ -215,10 +266,10 @@ class PlayerManager @Inject constructor(
} }
player.trackSelectionParameters = builder.build() player.trackSelectionParameters = builder.build()
// Save track preference if media context is available // Save track preference if media id is available
currentMediaContext?.let { context -> _currentMediaId.value?.let { mediaId ->
scope.launch { scope.launch {
saveTrackPreference(option, context.mediaId) saveTrackPreference(option, mediaId)
} }
} }
} }
@@ -235,23 +286,12 @@ class PlayerManager @Inject constructor(
player.playWhenReady = true player.playWhenReady = true
} }
fun playQueueItem(id: String) {
val items = _queue.value
val targetIndex = items.indexOfFirst { it.id == id }
if (targetIndex >= 0) {
clearPendingSeek()
player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true
}
}
fun clearError() { fun clearError() {
_playbackState.update { it.copy(error = null) } _playbackState.update { it.copy(error = null) }
} }
private fun applyTrackPreferences() { private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return val preferences = currentPlayableMedia.firstOrNull()?.preferences ?: return
val preferences = context.preferences
val currentTrackState = _tracks.value val currentTrackState = _tracks.value
@@ -274,7 +314,7 @@ class PlayerManager @Inject constructor(
} }
} }
private suspend fun saveTrackPreference(option: TrackOption, preferenceKey: String) { private suspend fun saveTrackPreference(option: TrackOption, mediaId: UUID) {
when (option.type) { when (option.type) {
TrackType.AUDIO -> { TrackType.AUDIO -> {
val properties = AudioTrackProperties( val properties = AudioTrackProperties(
@@ -282,7 +322,7 @@ class PlayerManager @Inject constructor(
channelCount = option.channelCount, channelCount = option.channelCount,
label = option.label label = option.label
) )
trackPreferencesRepository.saveAudioPreference(preferenceKey, properties) trackPreferencesRepository.saveAudioPreference(mediaId.toString(), properties)
} }
TrackType.TEXT -> { TrackType.TEXT -> {
@@ -292,7 +332,7 @@ class PlayerManager @Inject constructor(
label = option.label, label = option.label,
isOff = option.isOff isOff = option.isOff
) )
trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties) trackPreferencesRepository.saveSubtitlePreference(mediaId.toString(), properties)
} }
TrackType.VIDEO -> { TrackType.VIDEO -> {
@@ -346,24 +386,6 @@ class PlayerManager @Inject constructor(
playbackReportContext = playbackReportContext, playbackReportContext = playbackReportContext,
) )
_tracks.value = trackMapper.map(player.currentTracks) _tracks.value = trackMapper.map(player.currentTracks)
_queue.value = buildQueueSnapshot(player)
}
private fun buildQueueSnapshot(player: Player): List<QueueItemUi> {
val items = mutableListOf<QueueItemUi>()
for (i in 0 until player.mediaItemCount) {
val mediaItem = player.getMediaItemAt(i)
items.add(
QueueItemUi(
id = mediaItem.mediaId.ifEmpty { i.toString() },
title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}",
subtitle = mediaItem.mediaMetadata.subtitle?.toString(),
artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(),
isCurrent = i == player.currentMediaItemIndex
)
)
}
return items
} }
private fun clampSeekPosition(positionMs: Long): Long { private fun clampSeekPosition(positionMs: Long): Long {

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.player.model package hu.bbara.purefin.player.model
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.player.preference.MediaTrackPreferences import hu.bbara.purefin.model.MediaTrackPreferences
data class MediaContext( data class MediaContext(
val mediaId: String, val mediaId: String,

View File

@@ -14,7 +14,7 @@ data class PlayerUiState(
val playbackSpeed: Float = 1f, val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(), val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(), val ads: List<TimedMarker> = emptyList(),
val queue: List<QueueItemUi> = emptyList(), val queue: List<PlaylistElementUiModel> = emptyList(),
val audioTracks: List<TrackOption> = emptyList(), val audioTracks: List<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(), val textTracks: List<TrackOption> = emptyList(),
val qualityTracks: List<TrackOption> = emptyList(), val qualityTracks: List<TrackOption> = emptyList(),
@@ -47,10 +47,9 @@ data class TimedMarker(
enum class MarkerType { CHAPTER, AD } enum class MarkerType { CHAPTER, AD }
data class QueueItemUi( data class PlaylistElementUiModel(
val id: String, val id: String,
val title: String, val title: String,
val subtitle: String?,
val artworkUrl: String?, val artworkUrl: String?,
val isCurrent: Boolean val isCurrent: Boolean
) )

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.player.preference package hu.bbara.purefin.player.preference
import hu.bbara.purefin.model.AudioTrackProperties
import hu.bbara.purefin.model.SubtitleTrackProperties
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType import hu.bbara.purefin.player.model.TrackType
import javax.inject.Inject import javax.inject.Inject

View File

@@ -10,6 +10,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.model.TrackPreferences
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module

View File

@@ -1,6 +1,10 @@
package hu.bbara.purefin.player.preference package hu.bbara.purefin.player.preference
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import hu.bbara.purefin.model.AudioTrackProperties
import hu.bbara.purefin.model.MediaTrackPreferences
import hu.bbara.purefin.model.SubtitleTrackProperties
import hu.bbara.purefin.model.TrackPreferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.player.preference
import androidx.datastore.core.CorruptionException import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import hu.bbara.purefin.model.TrackPreferences
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream import java.io.InputStream

View File

@@ -1,21 +1,25 @@
package hu.bbara.purefin.player.viewmodel package hu.bbara.purefin.player.viewmodel
import android.util.Log
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.player.manager.PlayerManager import hu.bbara.purefin.player.manager.PlayerManager
import hu.bbara.purefin.player.manager.ProgressManager import hu.bbara.purefin.player.manager.ProgressManager
import hu.bbara.purefin.player.model.MediaContext
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.PlaylistElementUiModel
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.preference.TrackPreferencesRepository
import hu.bbara.purefin.data.PlayableMediaRepository
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,8 +31,7 @@ import javax.inject.Inject
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager, private val playerManager: PlayerManager,
private val playableMediaRepository: PlayableMediaRepository, private val mediaCatalogReader: MediaCatalogReader,
private val trackPreferencesRepository: TrackPreferencesRepository,
private val progressManager: ProgressManager, private val progressManager: ProgressManager,
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
@@ -47,7 +50,6 @@ class PlayerViewModel @Inject constructor(
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS) private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
private var autoHideJob: Job? = null private var autoHideJob: Job? = null
private var lastNextUpMediaId: String? = null
private var dataErrorMessage: String? = null private var dataErrorMessage: String? = null
init { init {
@@ -101,11 +103,6 @@ class PlayerViewModel @Inject constructor(
subtitle = metadata.subtitle subtitle = metadata.subtitle
) )
} }
val currentMediaId = metadata.mediaId
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
lastNextUpMediaId = currentMediaId
loadNextUp(currentMediaId)
}
} }
} }
@@ -125,73 +122,50 @@ class PlayerViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
playerManager.queue.collect { queue -> combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
_uiState.update { it.copy(queue = queue) } playlist.mapNotNull { playableMedia ->
playableMedia.toPlaylistElementUiModel(currentPlayableMedia?.id)
}
}.collect { queue ->
_uiState.update { state ->
state.copy(
queue = queue
)
}
} }
} }
} }
private fun loadInitialMedia() { private fun loadInitialMedia() {
val id = mediaId ?: return val id = mediaId ?: return
viewModelScope.launch {
loadMediaById(id) loadMediaById(id)
} }
}
fun loadMedia(id: String) { fun loadMedia(id: String) {
if (mediaId != null) return // Already loading from SavedStateHandle if (mediaId != null) return // Already loading from SavedStateHandle
viewModelScope.launch {
loadMediaById(id) loadMediaById(id)
} }
}
@OptIn(InternalSerializationApi::class) @OptIn(InternalSerializationApi::class)
private fun loadMediaById(id: String) { private suspend fun loadMediaById(id: String) {
val uuid = id.toUuidOrNull() val uuid = id.toUuidOrNull()
if (uuid == null) { if (uuid == null) {
dataErrorMessage = "Invalid media id" dataErrorMessage = "Invalid media id"
_uiState.update { it.copy(error = dataErrorMessage) } _uiState.update { it.copy(error = dataErrorMessage) }
return return
} }
//TODO hack to preload the series media
mediaCatalogReader.getEpisode(UUID.fromString(id))
viewModelScope.launch { viewModelScope.launch {
val result = playableMediaRepository.getMediaItem(uuid) runCatching {
if (result != null) { playerManager.play(uuid)
val (mediaItem, resumePositionMs) = result }.onFailure { e ->
_uiState.update { it.copy(error = e.message) }
// TODO use CatalogReader for this instead of episodeSeriesLookup
// val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid)
val preferenceKey = uuid.toString()
val preferences = trackPreferencesRepository.getMediaPreferences(preferenceKey).first()
val mediaSegments = playableMediaRepository.getMediaSegments(uuid)
val mediaContext = MediaContext(
mediaId = id,
preferences = preferences,
mediaSegments = mediaSegments
)
playerManager.play(mediaItem, mediaContext)
// Seek to resume position after play() is called
resumePositionMs?.let { playerManager.seekTo(it) }
if (dataErrorMessage != null) {
dataErrorMessage = null
_uiState.update { it.copy(error = null) }
} }
} else {
dataErrorMessage = "Unable to load media"
_uiState.update { it.copy(error = dataErrorMessage) }
}
}
}
private fun loadNextUp(currentMediaId: String) {
val uuid = currentMediaId.toUuidOrNull() ?: return
viewModelScope.launch {
val queuedIds = uiState.value.queue.map { it.id }.toSet()
val items = playableMediaRepository.getNextUpMediaItems(
episodeId = uuid,
existingIds = queuedIds,
)
items.forEach { playerManager.addToQueue(it) }
} }
} }
@@ -277,9 +251,9 @@ class PlayerViewModel @Inject constructor(
playerManager.retry() playerManager.retry()
} }
fun playQueueItem(id: String, autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { fun playQueueItem(id: String) {
playerManager.playQueueItem(id) playerManager.play(id.toUuidOrNull() ?: return)
showControls(autoHideDelayMs) showControls()
} }
fun clearError() { fun clearError() {
@@ -288,6 +262,64 @@ class PlayerViewModel @Inject constructor(
_uiState.update { it.copy(error = null) } _uiState.update { it.copy(error = null) }
} }
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
return when (this) {
is PlayableMedia.Movie -> {
val movie = mediaCatalogReader.getMovie(id).first()
if (movie == null) {
Log.e("PlayerViewModel", "Movie not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(
id = movie.id.toString(),
title = movie.title,
artworkUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
),
isCurrent = currentMediaId == movie.id
)
}
}
is PlayableMedia.Series -> {
val series = mediaCatalogReader.getSeries(id).first()
if (series == null) {
Log.e("PlayerViewModel", "Series not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(
id = series.id.toString(),
title = series.name,
artworkUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
),
isCurrent = currentMediaId == series.id
)
}
}
is PlayableMedia.Episode -> {
val episode = mediaCatalogReader.getEpisode(id).first()
if (episode == null) {
Log.e("PlayerViewModel", "Episode not found for playlist item: $id")
null
} else {
PlaylistElementUiModel(
id = episode.id.toString(),
title = episode.title,
artworkUrl = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
),
isCurrent = currentMediaId == episode.id
)
}
}
}
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
autoHideJob?.cancel() autoHideJob?.cancel()

View File

@@ -66,8 +66,6 @@ class MovieUiModel: MediaUiModel {
format = "", format = "",
synopsis = "", synopsis = "",
imageUrlPrefix = "", imageUrlPrefix = "",
audioTrack = "",
subtitles = "",
cast = emptyList() cast = emptyList()
) )
) )

View File

@@ -5,15 +5,11 @@ import androidx.datastore.core.DataStore
import hu.bbara.purefin.data.HomeRepository import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.NetworkMonitor import hu.bbara.purefin.data.NetworkMonitor
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.data.converter.toLibrary
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.model.Library import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.model.Media
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.offline.cache.HomeCache import hu.bbara.purefin.data.offline.cache.HomeCache
import hu.bbara.purefin.data.offline.cache.toCachedEpisode import hu.bbara.purefin.data.offline.cache.toCachedEpisode
@@ -26,6 +22,9 @@ import hu.bbara.purefin.data.offline.cache.toLibrary
import hu.bbara.purefin.data.offline.cache.toMedia import hu.bbara.purefin.data.offline.cache.toMedia
import hu.bbara.purefin.data.offline.cache.toMovie import hu.bbara.purefin.data.offline.cache.toMovie
import hu.bbara.purefin.data.offline.cache.toSeries import hu.bbara.purefin.data.offline.cache.toSeries
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -35,14 +34,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicBoolean
@@ -235,10 +229,10 @@ class InMemoryAppContentRepository @Inject constructor(
} }
return when (library.type) { return when (library.type) {
LibraryKind.MOVIES -> library.copy( LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl(), library.id) }, movies = contentItem.map { it.toMovie(serverUrl()) },
) )
LibraryKind.SERIES -> library.copy( LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl(), library.id) }, series = contentItem.map { it.toSeries(serverUrl()) },
) )
} }
} }
@@ -317,13 +311,13 @@ class InMemoryAppContentRepository @Inject constructor(
} }
library.id to when (library.collectionType) { library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map { CollectionType.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl(), library.id) val movie = it.toMovie(serverUrl())
Media.MovieMedia(movieId = movie.id) Media.MovieMedia(movieId = movie.id)
} }
CollectionType.TVSHOWS -> latestFromLibrary.map { CollectionType.TVSHOWS -> latestFromLibrary.map {
when (it.type) { when (it.type) {
BaseItemKind.SERIES -> { BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl(), library.id) val series = it.toSeries(serverUrl())
Media.SeriesMedia(seriesId = series.id) Media.SeriesMedia(seriesId = series.id)
} }
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
@@ -347,111 +341,6 @@ class InMemoryAppContentRepository @Inject constructor(
return userSessionRepository.serverUrl.first() return userSessionRepository.serverUrl.first()
} }
private fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(url = serverUrl, itemId = id, artworkKind = ArtworkKind.PRIMARY),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
return Movie(
id = id,
libraryId = libraryId,
title = name ?: "Unknown title",
progress = userData!!.playedPercentage,
watched = userData!!.played,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
synopsis = overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList(),
)
}
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
return Series(
id = id,
libraryId = libraryId,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList(),
)
}
private fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
}
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
companion object { companion object {
private const val TAG = "InMemoryAppContentRepo" private const val TAG = "InMemoryAppContentRepo"
} }

View File

@@ -1,12 +1,15 @@
package hu.bbara.purefin.data.catalog package hu.bbara.purefin.data.catalog
import android.util.Log
import hu.bbara.purefin.data.MediaRepository import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,15 +19,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -36,6 +36,8 @@ class InMemoryMediaRepository @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val serverUrl = userSessionRepository.serverUrl
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap()) private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow() override val movies: StateFlow<Map<UUID, Movie>> = moviesState.asStateFlow()
@@ -45,6 +47,50 @@ class InMemoryMediaRepository @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?> {
if (!moviesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.MOVIE) {
Log.d("InMemoryMediaRepository", "Item is not an movie: ${item.type}")
return flowOf(null)
}
val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) }
}
}
return moviesState.map { it[id] }
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
if (!seriesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) {
Log.d("InMemoryMediaRepository", "Item is not an series: ${item.type}")
return flowOf(null)
}
val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) }
}
}
return seriesState.map { it[id] }
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
if (!episodesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) {
Log.d("InMemoryMediaRepository", "Item is not an episode: ${item.type}")
return flowOf(null)
}
val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) }
}
}
val episode = episodesState.value[id] ?: return flowOf(null)
observeSeriesWithContent(seriesId = episode.seriesId)
return episodesState.map { it[id] }
}
fun upsertMovies(movies: List<Movie>) { fun upsertMovies(movies: List<Movie>) {
moviesState.update { current -> current + movies.associateBy { it.id } } moviesState.update { current -> current + movies.associateBy { it.id } }
} }
@@ -102,53 +148,4 @@ class InMemoryMediaRepository @Inject constructor(
val allEpisodes = filledSeasons.flatMap { it.episodes } val allEpisodes = filledSeasons.flatMap { it.episodes }
episodesState.update { current -> current + allEpisodes.associateBy { it.id } } episodesState.update { current -> current + allEpisodes.associateBy { it.id } }
} }
private fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) return fallbackYear?.toString() ?: ""
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
} }

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -31,6 +32,18 @@ class OfflineMediaRepository @Inject constructor(
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return movies.map { it[id] }
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return series.map { it[id] }
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return episodes.map { it[id] }
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> { override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
return localDataSource.observeSeriesWithContent(seriesId) return localDataSource.observeSeriesWithContent(seriesId)
} }

View File

@@ -0,0 +1,127 @@
package hu.bbara.purefin.data.converter
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit
fun BaseItemDto.toLibrary(serverUrl: String): Library {
return when (collectionType) {
CollectionType.MOVIES -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = serverUrl,
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.MOVIES,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
id = id,
name = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = serverUrl,
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.SERIES,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
}
}
fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie(
id = id,
libraryId = parentId!!,
title = name ?: "Unknown title",
progress = userData!!.playedPercentage,
watched = userData!!.played,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
synopsis = overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
cast = emptyList(),
)
}
fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId!!,
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = id),
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
seasonCount = childCount!!,
seasons = emptyList(),
cast = emptyList(),
)
}
fun BaseItemDto.toSeason(): Season {
return Season(
id = id,
seriesId = seriesId!!,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData!!.unplayedItemCount!!,
episodeCount = childCount!!,
episodes = emptyList(),
)
}
fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,
cast = emptyList(),
)
}
fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
}
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}

View File

@@ -9,18 +9,21 @@ import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.data.PlayableMediaRepository import hu.bbara.purefin.data.PlayableMediaRepository
import hu.bbara.purefin.data.PlaybackReportContext import hu.bbara.purefin.data.PlaybackReportContext
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.player.preference.TrackPreferencesRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO import org.jellyfin.sdk.model.api.MediaSegmentType.INTRO
import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO
@@ -35,14 +38,47 @@ import javax.inject.Singleton
class DefaultPlayableMediaRepository @Inject constructor( class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
private val trackPreferencesRepository: TrackPreferencesRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) : PlayableMediaRepository { ) : PlayableMediaRepository {
override suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) { override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) {
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return@withContext null
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val mediaItem = getMediaItem(baseItem, playbackDecision)
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first()
val mediaSegments = getMediaSegments(mediaId)
when (baseItem.type) {
BaseItemKind.MOVIE -> PlayableMedia.Movie(
id = mediaId,
mediaItem = mediaItem,
resumePositionMs = resumePositionMs ?: 0L,
preferences = mediaTrackPreferences,
mediaSegments = mediaSegments
)
BaseItemKind.SERIES -> PlayableMedia.Series(
id = mediaId,
mediaItem = mediaItem,
resumePositionMs = resumePositionMs ?: 0L,
preferences = mediaTrackPreferences,
mediaSegments = mediaSegments
)
BaseItemKind.EPISODE -> PlayableMedia.Episode(
id = mediaId,
mediaItem = mediaItem,
resumePositionMs = resumePositionMs ?: 0L,
preferences = mediaTrackPreferences,
mediaSegments = mediaSegments
)
else -> null
}
}
private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY) val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
@@ -50,46 +86,35 @@ class DefaultPlayableMediaRepository @Inject constructor(
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
playbackDecision = playbackDecision, playbackDecision = playbackDecision,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null, title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem), subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext, playbackReportContext = playbackDecision.reportContext,
) )
Pair(mediaItem, resumePositionMs) return@withContext mediaItem
} }
override suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> { private suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> {
val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId) val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId)
return mediaSegments.mapNotNull { return mediaSegments.mapNotNull {
it.toMediaSegment() it.toMediaSegment()
} }
} }
override suspend fun getNextUpMediaItems( override suspend fun getNextUpPlayableMedias(
episodeId: UUID, episodeId: UUID,
existingIds: Set<String>, existingIds: Set<UUID>,
count: Int, count: Int,
): List<MediaItem> = withContext(Dispatchers.IO) { ): List<PlayableMedia> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
episodes.mapNotNull { episode -> episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null val id = episode.id ?: return@mapNotNull null
val stringId = id.toString() if (existingIds.contains(id)) {
if (existingIds.contains(stringId)) {
return@mapNotNull null return@mapNotNull null
} }
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(id) ?: return@mapNotNull null getPlayableMedia(id)
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, id, ArtworkKind.PRIMARY)
createMediaItem(
mediaId = stringId,
playbackDecision = playbackDecision,
title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null,
subtitle = seasonEpisodeLabel(episode),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
)
} }
}.getOrElse { error -> }.getOrElse { error ->
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error) Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)

View File

@@ -244,9 +244,9 @@ class JellyfinApiClient @Inject constructor(
seriesId = seriesId, seriesId = seriesId,
enableUserData = true, enableUserData = true,
startItemId = episodeId, startItemId = episodeId,
limit = count + 1, limit = count,
) )
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1) val nextUpEpisodes = nextUpEpisodesResult.content.items
Log.d("getNextEpisodes", nextUpEpisodes.toString()) Log.d("getNextEpisodes", nextUpEpisodes.toString())
nextUpEpisodes nextUpEpisodes
} }

View File

@@ -1,83 +0,0 @@
package hu.bbara.purefin.data.jellyfin.download
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import java.util.UUID
import java.util.concurrent.TimeUnit
import org.jellyfin.sdk.model.api.BaseItemDto
internal fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
audioTrack = "ENG",
subtitles = "ENG",
cast = emptyList(),
)
}
internal fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId ?: UUID.randomUUID(),
seasonId = parentId ?: UUID.randomUUID(),
title = name ?: "Unknown title",
index = indexNumber ?: 0,
synopsis = overview ?: "No synopsis available.",
releaseDate = productionYear?.toString() ?: "",
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
cast = emptyList(),
)
}
internal fun BaseItemDto.toSeries(serverUrl: String): Series {
return Series(
id = id,
libraryId = parentId ?: UUID.randomUUID(),
name = name ?: "Unknown",
synopsis = overview ?: "No synopsis available",
year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
imageUrlPrefix = ImageUrlBuilder.toPrefixImageUrl(serverUrl, id),
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
seasonCount = childCount ?: 0,
seasons = emptyList(),
cast = emptyList(),
)
}
internal fun BaseItemDto.toSeason(seriesId: UUID): Season {
return Season(
id = id,
seriesId = this.seriesId ?: seriesId,
name = name ?: "Unknown",
index = indexNumber ?: 0,
unwatchedEpisodeCount = userData?.unplayedItemCount ?: 0,
episodeCount = childCount ?: 0,
episodes = emptyList(),
)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}

View File

@@ -5,16 +5,20 @@ import hu.bbara.purefin.data.EpisodeDownloadSource
import hu.bbara.purefin.data.MovieDownloadSource import hu.bbara.purefin.data.MovieDownloadSource
import hu.bbara.purefin.data.PlaybackMethod import hu.bbara.purefin.data.PlaybackMethod
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecisionResolver import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecisionResolver
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class JellyfinDownloadMediaSourceResolver @Inject constructor( class JellyfinDownloadMediaSourceResolver @Inject constructor(
@@ -49,7 +53,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl) val episode = episodeDto.toEpisode(serverUrl)
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason(series.id) ?: return@withContext null val season = jellyfinApiClient.getItemInfo(episode.seasonId)?.toSeason() ?: return@withContext null
EpisodeDownloadSource( EpisodeDownloadSource(
episode = episode, episode = episode,

View File

@@ -30,8 +30,6 @@ data class CachedMovie(
val format: String, val format: String,
val synopsis: String, val synopsis: String,
val imageUrlPrefix: String, val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CachedCastMember> = emptyList() val cast: List<CachedCastMember> = emptyList()
) )
@@ -156,8 +154,6 @@ fun Movie.toCachedMovie() = CachedMovie(
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCachedCastMember() }, cast = cast.map { it.toCachedCastMember() },
) )
@@ -176,8 +172,6 @@ fun CachedMovie.toMovie(): Movie? {
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCastMember() }, cast = cast.map { it.toCastMember() },
) )
} }

View File

@@ -21,6 +21,4 @@ data class MovieEntity(
val format: String, val format: String,
val synopsis: String, val synopsis: String,
val imageUrlPrefix: String, val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String
) )

View File

@@ -23,7 +23,7 @@ import hu.bbara.purefin.data.offline.room.entity.SmartDownloadEntity
EpisodeEntity::class, EpisodeEntity::class,
SmartDownloadEntity::class, SmartDownloadEntity::class,
], ],
version = 9, version = 10,
exportSchema = false exportSchema = false
) )
@TypeConverters(UuidConverters::class) @TypeConverters(UuidConverters::class)

View File

@@ -220,9 +220,7 @@ class OfflineRoomMediaLocalDataSource(
runtime = runtime, runtime = runtime,
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix
audioTrack = audioTrack,
subtitles = subtitles
) )
private fun Series.toEntity() = SeriesEntity( private fun Series.toEntity() = SeriesEntity(
@@ -273,8 +271,6 @@ class OfflineRoomMediaLocalDataSource(
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = emptyList() cast = emptyList()
) )

View File

@@ -1,78 +0,0 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class DefaultEpisodeSeriesLookupTest {
@Test
fun `returns series id for known episode`() = runBlocking {
val episodeId = UUID.randomUUID()
val seriesId = UUID.randomUUID()
val lookup = DefaultEpisodeSeriesLookup(
mediaCatalogReader = FakeMediaCatalogReader(
episodes = mapOf(
episodeId to episode(
id = episodeId,
seriesId = seriesId,
)
)
)
)
val result = lookup.preferenceKeyFor(episodeId)
assertEquals(seriesId.toString(), result)
}
@Test
fun `falls back to media id when episode is missing`() = runBlocking {
val mediaId = UUID.randomUUID()
val lookup = DefaultEpisodeSeriesLookup(
mediaCatalogReader = FakeMediaCatalogReader()
)
val result = lookup.preferenceKeyFor(mediaId)
assertEquals(mediaId.toString(), result)
}
private class FakeMediaCatalogReader(
movies: Map<UUID, Movie> = emptyMap(),
series: Map<UUID, Series> = emptyMap(),
episodes: Map<UUID, Episode> = emptyMap(),
) : MediaCatalogReader {
override val movies: StateFlow<Map<UUID, Movie>> = MutableStateFlow(movies)
override val series: StateFlow<Map<UUID, Series>> = MutableStateFlow(series)
override val episodes: StateFlow<Map<UUID, Episode>> = MutableStateFlow(episodes)
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> = flowOf(series.value[seriesId])
}
private fun episode(id: UUID, seriesId: UUID) = Episode(
id = id,
seriesId = seriesId,
seasonId = UUID.randomUUID(),
index = 1,
title = "Episode",
synopsis = "Synopsis",
releaseDate = "2026",
rating = "PG",
runtime = "42m",
progress = null,
watched = false,
format = "VIDEO",
imageUrlPrefix = "",
cast = emptyList(),
)
}