mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
8 Commits
2433ececac
...
9906d473c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 9906d473c5 | |||
| 1b8300ee1d | |||
| aa58cf3aff | |||
| 81a3504606 | |||
| b15453ab01 | |||
| f9eaf0cf84 | |||
| 78a5f716c0 | |||
| f7f9fc1058 |
@@ -51,8 +51,8 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":data"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":core-model"))
|
||||
implementation(project(":core-ui"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
|
||||
@@ -40,7 +40,7 @@ import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.TrackType
|
||||
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
||||
@@ -247,14 +247,14 @@ class TvPlayerControlsOverlayTest {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
queue = listOf(
|
||||
QueueItemUi(
|
||||
PlaylistElementUiModel(
|
||||
id = "first",
|
||||
title = "Episode 1",
|
||||
subtitle = "Fallback entry",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
QueueItemUi(
|
||||
PlaylistElementUiModel(
|
||||
id = "second",
|
||||
title = "Episode 2",
|
||||
subtitle = "Later",
|
||||
@@ -871,21 +871,21 @@ private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
||||
positionMs = 120_000,
|
||||
bufferedMs = 240_000,
|
||||
queue = listOf(
|
||||
QueueItemUi(
|
||||
PlaylistElementUiModel(
|
||||
id = "played",
|
||||
title = "Already Played",
|
||||
subtitle = "Episode 0",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
QueueItemUi(
|
||||
PlaylistElementUiModel(
|
||||
id = "current",
|
||||
title = "Currently Playing",
|
||||
subtitle = "Episode 1",
|
||||
artworkUrl = null,
|
||||
isCurrent = true
|
||||
),
|
||||
QueueItemUi(
|
||||
PlaylistElementUiModel(
|
||||
id = "next",
|
||||
title = "Episode 2",
|
||||
subtitle = "Up next",
|
||||
|
||||
@@ -13,21 +13,20 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
|
||||
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.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
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
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvEpisodeScreen(
|
||||
@@ -126,7 +125,7 @@ internal fun TvEpisodeScreenContent(
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = episode.cast)
|
||||
// MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
||||
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.MediaDetailPlaybackSection
|
||||
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.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvMovieScreen(
|
||||
@@ -91,8 +90,8 @@ internal fun TvMovieScreenContent(
|
||||
item(key = "movie-playback") {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = movie.audioTrack,
|
||||
subtitles = movie.subtitles,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
@@ -103,7 +102,7 @@ internal fun TvMovieScreenContent(
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = movie.cast)
|
||||
// MediaCastRow(cast = movie.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ fun TvPlayerScreen(
|
||||
onExpandPlaylist = expandPlaylist,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
||||
viewModel.playQueueItem(id)
|
||||
collapsePlaylistToControls()
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
|
||||
@@ -20,11 +20,11 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.style.TextOverflow
|
||||
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 hu.bbara.purefin.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.player.model.PlaylistElementUiModel
|
||||
|
||||
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
|
||||
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
|
||||
@@ -159,7 +159,7 @@ internal fun TvPlayerQueuePanel(
|
||||
|
||||
@Composable
|
||||
private fun TvQueueRowCard(
|
||||
item: QueueItemUi,
|
||||
item: PlaylistElementUiModel,
|
||||
isCurrent: Boolean,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
@@ -231,15 +231,6 @@ private fun TvQueueRowCard(
|
||||
maxLines = 2,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.MediaDetailSectionTitle
|
||||
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.TvSeasonTabs
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
||||
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.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.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
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 SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||
@@ -367,13 +367,13 @@ private fun TvEpisodeCard(
|
||||
|
||||
@Composable
|
||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
MediaCastRow(
|
||||
cast = cast,
|
||||
modifier = modifier,
|
||||
cardWidth = 84.dp,
|
||||
nameSize = 11.sp,
|
||||
roleSize = 10.sp
|
||||
)
|
||||
// MediaCastRow(
|
||||
// cast = cast,
|
||||
// modifier = modifier,
|
||||
// cardWidth = 84.dp,
|
||||
// nameSize = 11.sp,
|
||||
// roleSize = 10.sp
|
||||
// )
|
||||
}
|
||||
|
||||
private fun Episode.heroStatusText(): String {
|
||||
|
||||
@@ -30,19 +30,19 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
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.MovieTopBar
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -202,8 +202,6 @@ private fun previewMovie(): Movie =
|
||||
format = "Dolby Vision",
|
||||
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",
|
||||
audioTrack = "English 5.1",
|
||||
subtitles = "English CC",
|
||||
cast = listOf(
|
||||
CastMember("Ryan Gosling", "K", null),
|
||||
CastMember("Ana de Armas", "Joi", null),
|
||||
|
||||
@@ -125,8 +125,8 @@ internal fun MovieDetails(
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
audioTrack = movie.audioTrack,
|
||||
subtitles = movie.subtitles
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
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.style.TextOverflow
|
||||
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.ui.common.image.PurefinAsyncImage
|
||||
|
||||
@Composable
|
||||
fun PlayerQueuePanel(
|
||||
@@ -83,7 +82,6 @@ fun PlayerQueuePanel(
|
||||
items(uiState.queue, key = { item -> item.id }) { item ->
|
||||
QueueRow(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = item.isCurrent,
|
||||
onClick = { onSelect(item.id) }
|
||||
@@ -105,7 +103,7 @@ fun PlayerQueuePanel(
|
||||
@Composable
|
||||
private fun QueueRow(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
subtitle: String? = null,
|
||||
artworkUrl: String?,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
@@ -147,7 +145,7 @@ private fun QueueRow(
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (subtitle != null) {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
plugins {
|
||||
id("java-library")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.android.library)
|
||||
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
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,5 @@ data class Movie(
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val imageUrlPrefix: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
package hu.bbara.purefin.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -43,6 +43,21 @@ class CompositeMediaRepository @Inject constructor(
|
||||
.flatMapLatest { it.episodes }
|
||||
.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?> {
|
||||
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ interface MediaCatalogReader {
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
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?>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import hu.bbara.purefin.model.MediaSegment
|
||||
import hu.bbara.purefin.model.PlayableMedia
|
||||
import java.util.UUID
|
||||
|
||||
interface PlayableMediaRepository {
|
||||
suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>?
|
||||
suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment>
|
||||
suspend fun getNextUpMediaItems(
|
||||
suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia?
|
||||
suspend fun getNextUpPlayableMedias(
|
||||
episodeId: UUID,
|
||||
existingIds: Set<String>,
|
||||
count: Int = 9,
|
||||
): List<MediaItem>
|
||||
existingIds: Set<UUID>,
|
||||
count: Int,
|
||||
): List<PlayableMedia>
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.model.AudioTrackProperties
|
||||
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.PlaybackProgressSnapshot
|
||||
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.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.TrackPreferencesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -27,12 +27,16 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -44,6 +48,7 @@ import kotlin.math.abs
|
||||
class PlayerManager @Inject constructor(
|
||||
val player: Player,
|
||||
private val trackMapper: TrackMapper,
|
||||
private val playableMediaRepository: PlayableMediaRepository,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val trackMatcher: TrackMatcher
|
||||
) {
|
||||
@@ -55,23 +60,28 @@ class PlayerManager @Inject constructor(
|
||||
|
||||
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 val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||
|
||||
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
|
||||
|
||||
private val _progress = MutableStateFlow(PlaybackProgressSnapshot())
|
||||
|
||||
val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow()
|
||||
|
||||
private val _metadata = MutableStateFlow(MetadataState())
|
||||
|
||||
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
|
||||
|
||||
private val _tracks = MutableStateFlow(TrackSelectionState())
|
||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
|
||||
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
|
||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPositionDiscontinuity(
|
||||
@@ -89,9 +99,18 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
clearPendingSeek()
|
||||
currentMediaContext?.let {
|
||||
installMediaSegments(it.mediaSegments)
|
||||
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
|
||||
scope.launch {
|
||||
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()
|
||||
}
|
||||
|
||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
clearPendingSeek()
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
fun play(mediaId: UUID) {
|
||||
scope.launch {
|
||||
if (_playlist.value.map { it.id }.contains(mediaId)) {
|
||||
playFromPlaylist(mediaId)
|
||||
} else {
|
||||
playNewMedia(mediaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToQueue(mediaItem: MediaItem) {
|
||||
player.addMediaItem(mediaItem)
|
||||
private fun playFromPlaylist(mediaId: UUID) {
|
||||
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() {
|
||||
@@ -215,10 +266,10 @@ class PlayerManager @Inject constructor(
|
||||
}
|
||||
player.trackSelectionParameters = builder.build()
|
||||
|
||||
// Save track preference if media context is available
|
||||
currentMediaContext?.let { context ->
|
||||
// Save track preference if media id is available
|
||||
_currentMediaId.value?.let { mediaId ->
|
||||
scope.launch {
|
||||
saveTrackPreference(option, context.mediaId)
|
||||
saveTrackPreference(option, mediaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,23 +286,12 @@ class PlayerManager @Inject constructor(
|
||||
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() {
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private fun applyTrackPreferences() {
|
||||
val context = currentMediaContext ?: return
|
||||
val preferences = context.preferences
|
||||
private suspend fun applyTrackPreferences() {
|
||||
val preferences = currentPlayableMedia.firstOrNull()?.preferences ?: return
|
||||
|
||||
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) {
|
||||
TrackType.AUDIO -> {
|
||||
val properties = AudioTrackProperties(
|
||||
@@ -282,7 +322,7 @@ class PlayerManager @Inject constructor(
|
||||
channelCount = option.channelCount,
|
||||
label = option.label
|
||||
)
|
||||
trackPreferencesRepository.saveAudioPreference(preferenceKey, properties)
|
||||
trackPreferencesRepository.saveAudioPreference(mediaId.toString(), properties)
|
||||
}
|
||||
|
||||
TrackType.TEXT -> {
|
||||
@@ -292,7 +332,7 @@ class PlayerManager @Inject constructor(
|
||||
label = option.label,
|
||||
isOff = option.isOff
|
||||
)
|
||||
trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties)
|
||||
trackPreferencesRepository.saveSubtitlePreference(mediaId.toString(), properties)
|
||||
}
|
||||
|
||||
TrackType.VIDEO -> {
|
||||
@@ -346,24 +386,6 @@ class PlayerManager @Inject constructor(
|
||||
playbackReportContext = playbackReportContext,
|
||||
)
|
||||
_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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package hu.bbara.purefin.player.model
|
||||
|
||||
import hu.bbara.purefin.model.MediaSegment
|
||||
import hu.bbara.purefin.player.preference.MediaTrackPreferences
|
||||
import hu.bbara.purefin.model.MediaTrackPreferences
|
||||
|
||||
data class MediaContext(
|
||||
val mediaId: String,
|
||||
|
||||
@@ -14,7 +14,7 @@ data class PlayerUiState(
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
val queue: List<QueueItemUi> = emptyList(),
|
||||
val queue: List<PlaylistElementUiModel> = emptyList(),
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val qualityTracks: List<TrackOption> = emptyList(),
|
||||
@@ -47,10 +47,9 @@ data class TimedMarker(
|
||||
|
||||
enum class MarkerType { CHAPTER, AD }
|
||||
|
||||
data class QueueItemUi(
|
||||
data class PlaylistElementUiModel(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val artworkUrl: String?,
|
||||
val isCurrent: Boolean
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.TrackType
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -10,6 +10,7 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.model.TrackPreferences
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package hu.bbara.purefin.player.preference
|
||||
|
||||
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.map
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -2,6 +2,7 @@ package hu.bbara.purefin.player.preference
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import hu.bbara.purefin.model.TrackPreferences
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
package hu.bbara.purefin.player.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.ProgressManager
|
||||
import hu.bbara.purefin.player.model.MediaContext
|
||||
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.preference.TrackPreferencesRepository
|
||||
import hu.bbara.purefin.data.PlayableMediaRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -27,8 +31,7 @@ import javax.inject.Inject
|
||||
class PlayerViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val playerManager: PlayerManager,
|
||||
private val playableMediaRepository: PlayableMediaRepository,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val progressManager: ProgressManager,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
@@ -47,7 +50,6 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
|
||||
init {
|
||||
@@ -101,11 +103,6 @@ class PlayerViewModel @Inject constructor(
|
||||
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 {
|
||||
playerManager.queue.collect { queue ->
|
||||
_uiState.update { it.copy(queue = queue) }
|
||||
combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
|
||||
playlist.mapNotNull { playableMedia ->
|
||||
playableMedia.toPlaylistElementUiModel(currentPlayableMedia?.id)
|
||||
}
|
||||
}.collect { queue ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
queue = queue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInitialMedia() {
|
||||
val id = mediaId ?: return
|
||||
viewModelScope.launch {
|
||||
loadMediaById(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMedia(id: String) {
|
||||
if (mediaId != null) return // Already loading from SavedStateHandle
|
||||
viewModelScope.launch {
|
||||
loadMediaById(id)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
private fun loadMediaById(id: String) {
|
||||
private suspend fun loadMediaById(id: String) {
|
||||
val uuid = id.toUuidOrNull()
|
||||
if (uuid == null) {
|
||||
dataErrorMessage = "Invalid media id"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
return
|
||||
}
|
||||
//TODO hack to preload the series media
|
||||
mediaCatalogReader.getEpisode(UUID.fromString(id))
|
||||
viewModelScope.launch {
|
||||
val result = playableMediaRepository.getMediaItem(uuid)
|
||||
if (result != null) {
|
||||
val (mediaItem, resumePositionMs) = result
|
||||
|
||||
|
||||
// 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) }
|
||||
runCatching {
|
||||
playerManager.play(uuid)
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(error = e.message) }
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
|
||||
fun playQueueItem(id: String, autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.playQueueItem(id)
|
||||
showControls(autoHideDelayMs)
|
||||
fun playQueueItem(id: String) {
|
||||
playerManager.play(id.toUuidOrNull() ?: return)
|
||||
showControls()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
@@ -288,6 +262,64 @@ class PlayerViewModel @Inject constructor(
|
||||
_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() {
|
||||
super.onCleared()
|
||||
autoHideJob?.cancel()
|
||||
|
||||
@@ -66,8 +66,6 @@ class MovieUiModel: MediaUiModel {
|
||||
format = "",
|
||||
synopsis = "",
|
||||
imageUrlPrefix = "",
|
||||
audioTrack = "",
|
||||
subtitles = "",
|
||||
cast = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,15 +5,11 @@ import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.data.HomeRepository
|
||||
import hu.bbara.purefin.data.NetworkMonitor
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
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.Media
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.data.converter.toEpisode
|
||||
import hu.bbara.purefin.data.converter.toLibrary
|
||||
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.offline.cache.HomeCache
|
||||
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.toMovie
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -35,14 +34,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
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.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
@@ -235,10 +229,10 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
}
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
movies = contentItem.map { it.toMovie(serverUrl(), library.id) },
|
||||
movies = contentItem.map { it.toMovie(serverUrl()) },
|
||||
)
|
||||
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) {
|
||||
CollectionType.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(serverUrl(), library.id)
|
||||
val movie = it.toMovie(serverUrl())
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
CollectionType.TVSHOWS -> latestFromLibrary.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = it.toSeries(serverUrl(), library.id)
|
||||
val series = it.toSeries(serverUrl())
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
@@ -347,111 +341,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
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 {
|
||||
private const val TAG = "InMemoryAppContentRepo"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package hu.bbara.purefin.data.catalog
|
||||
|
||||
import android.util.Log
|
||||
import hu.bbara.purefin.data.MediaRepository
|
||||
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.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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,15 +19,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -36,6 +36,8 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val serverUrl = userSessionRepository.serverUrl
|
||||
|
||||
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
|
||||
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())
|
||||
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>) {
|
||||
moviesState.update { current -> current + movies.associateBy { it.id } }
|
||||
}
|
||||
@@ -102,53 +148,4 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
val allEpisodes = filledSeasons.flatMap { it.episodes }
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -31,6 +32,18 @@ class OfflineMediaRepository @Inject constructor(
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.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?> {
|
||||
return localDataSource.observeSeriesWithContent(seriesId)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -9,18 +9,21 @@ import androidx.media3.common.util.UnstableApi
|
||||
import hu.bbara.purefin.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.data.PlaybackReportContext
|
||||
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.playback.JellyfinPlaybackResolver
|
||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
|
||||
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.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
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.MediaSegmentType.INTRO
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO
|
||||
@@ -35,14 +38,47 @@ import javax.inject.Singleton
|
||||
class DefaultPlayableMediaRepository @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
) : 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 baseItem = jellyfinApiClient.getItemInfo(mediaId)
|
||||
|
||||
val mediaItem = getMediaItem(baseItem, playbackDecision)
|
||||
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 artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
|
||||
@@ -50,46 +86,35 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
||||
val mediaItem = createMediaItem(
|
||||
mediaId = mediaId.toString(),
|
||||
playbackDecision = playbackDecision,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null,
|
||||
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
|
||||
subtitle = seasonEpisodeLabel(baseItem),
|
||||
artworkUrl = artworkUrl,
|
||||
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)
|
||||
return mediaSegments.mapNotNull {
|
||||
it.toMediaSegment()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNextUpMediaItems(
|
||||
override suspend fun getNextUpPlayableMedias(
|
||||
episodeId: UUID,
|
||||
existingIds: Set<String>,
|
||||
existingIds: Set<UUID>,
|
||||
count: Int,
|
||||
): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
): List<PlayableMedia> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
|
||||
episodes.mapNotNull { episode ->
|
||||
val id = episode.id ?: return@mapNotNull null
|
||||
val stringId = id.toString()
|
||||
if (existingIds.contains(stringId)) {
|
||||
if (existingIds.contains(id)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(id) ?: return@mapNotNull null
|
||||
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,
|
||||
)
|
||||
getPlayableMedia(id)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error)
|
||||
|
||||
@@ -244,9 +244,9 @@ class JellyfinApiClient @Inject constructor(
|
||||
seriesId = seriesId,
|
||||
enableUserData = true,
|
||||
startItemId = episodeId,
|
||||
limit = count + 1,
|
||||
limit = count,
|
||||
)
|
||||
val nextUpEpisodes = nextUpEpisodesResult.content.items.drop(1)
|
||||
val nextUpEpisodes = nextUpEpisodesResult.content.items
|
||||
Log.d("getNextEpisodes", nextUpEpisodes.toString())
|
||||
nextUpEpisodes
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -5,16 +5,20 @@ import hu.bbara.purefin.data.EpisodeDownloadSource
|
||||
import hu.bbara.purefin.data.MovieDownloadSource
|
||||
import hu.bbara.purefin.data.PlaybackMethod
|
||||
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.playback.PlaybackDecisionResolver
|
||||
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.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
@@ -49,7 +53,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
|
||||
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
|
||||
val episode = episodeDto.toEpisode(serverUrl)
|
||||
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(
|
||||
episode = episode,
|
||||
|
||||
@@ -30,8 +30,6 @@ data class CachedMovie(
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val imageUrlPrefix: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String,
|
||||
val cast: List<CachedCastMember> = emptyList()
|
||||
)
|
||||
|
||||
@@ -156,8 +154,6 @@ fun Movie.toCachedMovie() = CachedMovie(
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = cast.map { it.toCachedCastMember() },
|
||||
)
|
||||
|
||||
@@ -176,8 +172,6 @@ fun CachedMovie.toMovie(): Movie? {
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = cast.map { it.toCastMember() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,4 @@ data class MovieEntity(
|
||||
val format: String,
|
||||
val synopsis: String,
|
||||
val imageUrlPrefix: String,
|
||||
val audioTrack: String,
|
||||
val subtitles: String
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ import hu.bbara.purefin.data.offline.room.entity.SmartDownloadEntity
|
||||
EpisodeEntity::class,
|
||||
SmartDownloadEntity::class,
|
||||
],
|
||||
version = 9,
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(UuidConverters::class)
|
||||
|
||||
@@ -220,9 +220,7 @@ class OfflineRoomMediaLocalDataSource(
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles
|
||||
imageUrlPrefix = imageUrlPrefix
|
||||
)
|
||||
|
||||
private fun Series.toEntity() = SeriesEntity(
|
||||
@@ -273,8 +271,6 @@ class OfflineRoomMediaLocalDataSource(
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
audioTrack = audioTrack,
|
||||
subtitles = subtitles,
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user