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 {
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
kotlin {
|
||||||
compilerOptions {
|
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 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ class MovieUiModel: MediaUiModel {
|
|||||||
format = "",
|
format = "",
|
||||||
synopsis = "",
|
synopsis = "",
|
||||||
imageUrlPrefix = "",
|
imageUrlPrefix = "",
|
||||||
audioTrack = "",
|
|
||||||
subtitles = "",
|
|
||||||
cast = emptyList()
|
cast = emptyList()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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