mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
refactor: centralize playable media state
Move playback-related media state into shared model types and make the player consume a single PlayableMedia object instead of separately loading MediaItems, track preferences, resume positions, and media segments. This updates PlayableMediaRepository to return playable media bundles, seeds the player playlist with the current item, keeps next-up entries in the same model, restores resume/segment handling, and fixes current queue highlighting. The change also moves track preference models into core-model, centralizes Jellyfin BaseItemDto conversion, removes placeholder movie audio/subtitle fields, bumps the offline Room schema, and updates mobile/TV queue UI code for the playlist model. Verification: ./gradlew --no-daemon :core-model:compileDebugKotlin :core:compileDebugKotlin :data:compileDebugKotlin :app:compileDebugKotlin
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
android {
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
namespace = "hu.bbara.purefin.model"
|
||||||
}
|
compileSdk = 36
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
defaultConfig {
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
|
minSdk = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
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,12 @@
|
|||||||
|
package hu.bbara.purefin.model
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class PlayableMedia (
|
||||||
|
val id: UUID,
|
||||||
|
val resumePositionMs: Float,
|
||||||
|
val mediaItem: MediaItem,
|
||||||
|
val preferences: MediaTrackPreferences,
|
||||||
|
val mediaSegments: List<MediaSegment>
|
||||||
|
)
|
||||||
@@ -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,24 @@ 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) }
|
||||||
|
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSeries(id: UUID): Flow<Series?> {
|
||||||
|
return activeRepository
|
||||||
|
.flatMapLatest { it.getSeries(id) }
|
||||||
|
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
|
||||||
|
return activeRepository
|
||||||
|
.flatMapLatest { it.getEpisode(id) }
|
||||||
|
.stateIn(scope, SharingStarted.Companion.Eagerly, null)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -28,11 +28,15 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
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.stateIn
|
||||||
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,7 +60,13 @@ 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)
|
||||||
|
val currentPlayableMedia: StateFlow<PlayableMedia?> by lazy {
|
||||||
|
combine(_currentMediaId, _playlist) { mediaId, playlist ->
|
||||||
|
playlist.firstOrNull { it.id == mediaId }
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
}
|
||||||
|
|
||||||
private var pendingSeekPositionMs: Long? = null
|
private var pendingSeekPositionMs: Long? = null
|
||||||
|
|
||||||
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||||
@@ -70,8 +81,8 @@ class PlayerManager @Inject constructor(
|
|||||||
private val _tracks = MutableStateFlow(TrackSelectionState())
|
private val _tracks = MutableStateFlow(TrackSelectionState())
|
||||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||||
|
|
||||||
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
|
private val _playlist = MutableStateFlow(emptyList<PlayableMedia>())
|
||||||
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
|
val playlist: StateFlow<List<PlayableMedia>> = _playlist.asStateFlow()
|
||||||
|
|
||||||
private val listener = object : Player.Listener {
|
private val listener = object : Player.Listener {
|
||||||
override fun onPositionDiscontinuity(
|
override fun onPositionDiscontinuity(
|
||||||
@@ -89,9 +100,9 @@ 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)
|
updatePlaylist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +122,49 @@ 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)
|
||||||
|
} else {
|
||||||
|
playNewMedia(mediaId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playFromPlaylist(mediaId: UUID) {
|
||||||
|
val index = _playlist.value.indexOfFirst { it.id == mediaId }
|
||||||
|
if (index != -1) {
|
||||||
|
player.seekToDefaultPosition(index)
|
||||||
|
player.playWhenReady = true
|
||||||
|
} else {
|
||||||
|
_playbackState.update { it.copy(error = "Media not found in playlist") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun playNewMedia(mediaId: UUID) {
|
||||||
|
val playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
|
||||||
|
if (playableMedia == null) {
|
||||||
|
_playbackState.update { it.copy(error = "Media not found") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player.setMediaItem(playableMedia.mediaItem)
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(mediaItem: MediaItem) {
|
private suspend fun updatePlaylist() {
|
||||||
player.addMediaItem(mediaItem)
|
val nextUpPlayableMedias = playableMediaRepository.getNextUpPlayableMedias(
|
||||||
|
episodeId = _currentMediaId.value ?: return,
|
||||||
|
existingIds = _playlist.value.map { it.id }.toSet(),
|
||||||
|
count = 5
|
||||||
|
)
|
||||||
|
addToQueue(nextUpPlayableMedias)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToQueue(playableMedias: List<PlayableMedia>) {
|
||||||
|
_playlist.update { it + playableMedias }
|
||||||
|
player.addMediaItems(playableMedias.map { it.mediaItem })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
@@ -215,10 +259,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 +279,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 fun applyTrackPreferences() {
|
||||||
val context = currentMediaContext ?: return
|
val preferences = currentPlayableMedia.value?.preferences ?: return
|
||||||
val preferences = context.preferences
|
|
||||||
|
|
||||||
val currentTrackState = _tracks.value
|
val currentTrackState = _tracks.value
|
||||||
|
|
||||||
@@ -274,7 +307,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 +315,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 +325,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 +379,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
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ 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.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
|
||||||
@@ -27,8 +26,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 +45,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 +98,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,76 +117,63 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerManager.queue.collect { queue ->
|
val currentPlayableMedia = playerManager.currentPlayableMedia
|
||||||
_uiState.update { it.copy(queue = queue) }
|
playerManager.playlist.collect { playlist ->
|
||||||
|
val episodes =
|
||||||
|
playlist.map { playableMedia -> mediaCatalogReader.getEpisode(playableMedia.id) }
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
queue = episodes.mapNotNull { episode ->
|
||||||
|
val episodeValue = episode.first()
|
||||||
|
if (episodeValue == null) {
|
||||||
|
throw IllegalStateException("Episode not found for media id: $episodeValue")
|
||||||
|
}
|
||||||
|
PlaylistElementUiModel(
|
||||||
|
id = episodeValue.id.toString(),
|
||||||
|
title = episodeValue.title,
|
||||||
|
artworkUrl = episodeValue.imageUrlPrefix,
|
||||||
|
isCurrent = currentPlayableMedia.value?.id == episodeValue.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadInitialMedia() {
|
private fun loadInitialMedia() {
|
||||||
val id = mediaId ?: return
|
val id = mediaId ?: return
|
||||||
loadMediaById(id)
|
viewModelScope.launch {
|
||||||
|
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
|
||||||
loadMediaById(id)
|
viewModelScope.launch {
|
||||||
|
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||||
playerManager.togglePlayPause()
|
playerManager.togglePlayPause()
|
||||||
showControls(autoHideDelayMs)
|
showControls(autoHideDelayMs)
|
||||||
@@ -277,9 +256,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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package hu.bbara.purefin.data.catalog
|
|||||||
|
|
||||||
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
|
||||||
@@ -19,12 +21,7 @@ import kotlinx.coroutines.flow.first
|
|||||||
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 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 +33,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 +44,39 @@ 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 ->
|
||||||
|
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 ->
|
||||||
|
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 ->
|
||||||
|
val episode = item.toEpisode(serverUrl.first())
|
||||||
|
episodesState.update { current -> current + (episode.id to episode) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val episodeFlow = episodesState.map { it[id] }
|
||||||
|
val seriesId = episodeFlow.first()!!.seriesId
|
||||||
|
observeSeriesWithContent(seriesId = seriesId)
|
||||||
|
return episodeFlow
|
||||||
|
}
|
||||||
|
|
||||||
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 +134,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,14 +9,16 @@ 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
|
||||||
@@ -35,14 +37,30 @@ 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)
|
||||||
|
PlayableMedia(
|
||||||
|
id = mediaId,
|
||||||
|
mediaItem = mediaItem,
|
||||||
|
resumePositionMs = resumePositionMs?.toFloat() ?: 0f,
|
||||||
|
preferences = mediaTrackPreferences,
|
||||||
|
mediaSegments = mediaSegments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +68,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