mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
6 Commits
2d649cdff9
...
660b50d7cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 660b50d7cd | |||
| 7472b35d66 | |||
| d8de27c972 | |||
| e33a92271a | |||
| 869bb27497 | |||
| a4bb57822a |
@@ -78,10 +78,10 @@ internal fun TvSeriesScreenContent(
|
||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||
mutableStateOf(defaultSeason?.id)
|
||||
}
|
||||
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val initialFocusSeasonId = defaultSeason?.id
|
||||
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
||||
series.seasons.firstOrNull { it.id == seasonId }
|
||||
series.allSeasons.firstOrNull { it.id == seasonId }
|
||||
} ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||
@@ -120,7 +120,7 @@ internal fun TvSeriesScreenContent(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (selectedSeason != null) {
|
||||
TvSeasonTabs(
|
||||
seasons = series.seasons,
|
||||
seasons = series.allSeasons,
|
||||
selectedSeason = selectedSeason,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
@@ -156,10 +156,10 @@ internal fun TvSeriesScreenContent(
|
||||
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||
if (focusedSeasonId != null) {
|
||||
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
}
|
||||
|
||||
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull()
|
||||
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Season.nextUpEpisode(): Episode? {
|
||||
|
||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -67,8 +68,9 @@ import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
||||
private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
|
||||
private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
|
||||
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
|
||||
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -90,7 +92,11 @@ fun PlayerScreen(
|
||||
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
|
||||
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
|
||||
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
|
||||
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
|
||||
var isAutoBrightness by remember { mutableStateOf(false) }
|
||||
var dragOvershoot by remember { mutableFloatStateOf(0f) }
|
||||
var autoDragTick by remember { mutableStateOf(0) }
|
||||
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
|
||||
var showQueuePanel by remember { mutableStateOf(false) }
|
||||
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
@@ -131,9 +137,61 @@ fun PlayerScreen(
|
||||
toggleControlsVisibility()
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
fun onBrightnessDragStart(isLeftSide: Boolean) {
|
||||
if (isLeftSide) {
|
||||
dragOvershoot = 0f
|
||||
}
|
||||
}
|
||||
fun onBrightnessDragEnd() {
|
||||
dragOvershoot = 0f
|
||||
}
|
||||
fun onBrightnessDrag(delta: Float) {
|
||||
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
|
||||
if (isAutoBrightness) {
|
||||
if (diff > 0f) {
|
||||
dragOvershoot += diff
|
||||
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
|
||||
isAutoBrightness = false
|
||||
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
|
||||
dragOvershoot = 0f
|
||||
applyBrightness(activity, brightness, isAuto = false)
|
||||
}
|
||||
} else {
|
||||
autoDragTick++
|
||||
}
|
||||
} else {
|
||||
val newBrightness = brightness + diff
|
||||
if (newBrightness <= 0f) {
|
||||
brightness = 0f
|
||||
applyBrightness(activity, brightness, isAuto = false)
|
||||
dragOvershoot += newBrightness
|
||||
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
|
||||
isAutoBrightness = true
|
||||
dragOvershoot = 0f
|
||||
applyBrightness(activity, brightness, isAuto = true)
|
||||
}
|
||||
} else {
|
||||
brightness = newBrightness.coerceIn(0f, 1f)
|
||||
dragOvershoot = 0f
|
||||
applyBrightness(activity, brightness, isAuto = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onVolumeDrag(delta: Float) {
|
||||
val diff = -delta / 800f
|
||||
volume = (volume + diff).coerceIn(0f, 1f)
|
||||
audioManager.setStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
(volume * maxVolume).roundToInt(),
|
||||
0
|
||||
)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
restoreBrightness(activity, originalScreenBrightness)
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
@@ -169,20 +227,10 @@ fun PlayerScreen(
|
||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||
onVerticalDragLeft = { delta ->
|
||||
val diff = (-delta / 800f)
|
||||
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
|
||||
applyBrightness(activity, brightness)
|
||||
},
|
||||
onVerticalDragRight = { delta ->
|
||||
val diff = (-delta / 800f)
|
||||
volume = (volume + diff).coerceIn(0f, 1f)
|
||||
audioManager.setStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
(volume * maxVolume).roundToInt(),
|
||||
0
|
||||
)
|
||||
},
|
||||
onVerticalDragStart = ::onBrightnessDragStart,
|
||||
onVerticalDragEnd = ::onBrightnessDragEnd,
|
||||
onVerticalDragLeft = ::onBrightnessDrag,
|
||||
onVerticalDragRight = ::onVolumeDrag,
|
||||
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
|
||||
horizontalSeekFeedback = deltaMs
|
||||
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
|
||||
@@ -230,23 +278,21 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
ValueChangeTimedVisibility(
|
||||
value = brightness,
|
||||
value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
|
||||
hideAfterMillis = 800,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 20.dp)
|
||||
) { currentBrightness ->
|
||||
) { state ->
|
||||
PlayerAdjustmentIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
icon = Icons.Outlined.BrightnessMedium,
|
||||
contentDescription = "Brightness",
|
||||
value = currentBrightness,
|
||||
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
|
||||
value = state.brightness,
|
||||
bottomText = if (state.isAuto) {
|
||||
"Auto"
|
||||
} else if (currentBrightness >= 0f) {
|
||||
"${(currentBrightness * 100).roundToInt()}%"
|
||||
} else {
|
||||
null
|
||||
"${(state.brightness * 100).roundToInt()}%"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -434,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCurrentBrightness(activity: Activity?): Float {
|
||||
val current = activity?.window?.attributes?.screenBrightness
|
||||
return if (current != null && current >= 0) current else -1f
|
||||
private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
|
||||
|
||||
private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
|
||||
val windowBrightness = activity?.window?.attributes?.screenBrightness
|
||||
if (windowBrightness != null && windowBrightness >= 0f) {
|
||||
return windowBrightness
|
||||
}
|
||||
return try {
|
||||
val systemBrightness = Settings.System.getInt(
|
||||
context.contentResolver,
|
||||
Settings.System.SCREEN_BRIGHTNESS
|
||||
)
|
||||
(systemBrightness / 255f).coerceIn(0f, 1f)
|
||||
} catch (e: Settings.SettingNotFoundException) {
|
||||
0.5f
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyBrightness(activity: Activity?, value: Float) {
|
||||
private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
|
||||
activity ?: return
|
||||
val params = activity.window.attributes
|
||||
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
|
||||
params.screenBrightness = if (isAuto) -1f else brightness
|
||||
activity.window.attributes = params
|
||||
}
|
||||
|
||||
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
|
||||
activity ?: return
|
||||
val params = activity.window.attributes
|
||||
params.screenBrightness = originalValue
|
||||
activity.window.attributes = params
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ fun PlayerGesturesLayer(
|
||||
onDoubleTapLeft: () -> Unit,
|
||||
onVerticalDragLeft: (delta: Float) -> Unit,
|
||||
onVerticalDragRight: (delta: Float) -> Unit,
|
||||
onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
|
||||
onVerticalDragEnd: () -> Unit = {},
|
||||
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
|
||||
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
|
||||
currentPositionProvider: () -> Long,
|
||||
@@ -79,6 +81,7 @@ fun PlayerGesturesLayer(
|
||||
var isHorizontalDragActive = false
|
||||
var lastPreviewDelta: Long? = null
|
||||
var startPositionMs = 0L
|
||||
var verticalDragStarted = false
|
||||
|
||||
drag(down.id) { change ->
|
||||
val delta = change.positionChange()
|
||||
@@ -115,6 +118,10 @@ fun PlayerGesturesLayer(
|
||||
DragDirection.VERTICAL -> {
|
||||
change.consume()
|
||||
val isLeftSide = startX < size.width / 2
|
||||
if (!verticalDragStarted) {
|
||||
verticalDragStarted = true
|
||||
onVerticalDragStart(isLeftSide)
|
||||
}
|
||||
if (isLeftSide) {
|
||||
onVerticalDragLeft(delta.y)
|
||||
} else {
|
||||
@@ -134,6 +141,10 @@ fun PlayerGesturesLayer(
|
||||
}
|
||||
}
|
||||
onHorizontalDragPreview(null, null)
|
||||
|
||||
if (verticalDragStarted) {
|
||||
onVerticalDragEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -149,12 +149,12 @@ private fun SeriesScreenInternal(
|
||||
var showMarkAsWatchedDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun getDefaultSeason(): Season? {
|
||||
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
|
||||
return series.allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.allSeasons.firstOrNull()
|
||||
}
|
||||
|
||||
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
|
||||
val selectedSeason =
|
||||
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||
series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
|
||||
?: selectedSeason?.episodes?.firstOrNull()
|
||||
val playAction = remember(nextUpEpisode, offline) {
|
||||
@@ -210,7 +210,7 @@ private fun SeriesScreenInternal(
|
||||
) { _modifier ->
|
||||
if (selectedSeason != null) {
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
seasons = series.allSeasons,
|
||||
selectedSeason = selectedSeason,
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = _modifier
|
||||
|
||||
@@ -2,6 +2,10 @@ package hu.bbara.purefin.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
val UNCATEGORIZED_SEASON_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000000")
|
||||
val UNCATEGORIZED_SERIES_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000001")
|
||||
const val UNCATEGORIZED_LABEL: String = "Uncategorized"
|
||||
|
||||
data class Series(
|
||||
val id: UUID,
|
||||
val libraryId: UUID,
|
||||
@@ -12,5 +16,27 @@ data class Series(
|
||||
val unwatchedEpisodeCount: Int,
|
||||
val seasonCount: Int,
|
||||
val seasons: List<Season>,
|
||||
val uncategorizedEpisodes: List<Episode> = emptyList(),
|
||||
val cast: List<CastMember>
|
||||
)
|
||||
) {
|
||||
|
||||
/**
|
||||
* Real seasons plus a synthetic "Uncategorized" season appended at the end
|
||||
* when [uncategorizedEpisodes] is non-empty. Used by the UI for the season
|
||||
* tab list so uncategorized episodes are selectable like any other season.
|
||||
*/
|
||||
val allSeasons: List<Season>
|
||||
get() = if (uncategorizedEpisodes.isEmpty()) {
|
||||
seasons
|
||||
} else {
|
||||
seasons + Season(
|
||||
id = UNCATEGORIZED_SEASON_ID,
|
||||
seriesId = id,
|
||||
name = UNCATEGORIZED_LABEL,
|
||||
index = 0,
|
||||
unwatchedEpisodeCount = uncategorizedEpisodes.count { !it.watched },
|
||||
episodeCount = uncategorizedEpisodes.size,
|
||||
episodes = uncategorizedEpisodes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.data.catalog
|
||||
import hu.bbara.purefin.core.concurrency.SingleFlight
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.data.converter.isUncategorizedEpisode
|
||||
import hu.bbara.purefin.data.converter.toEpisode
|
||||
import hu.bbara.purefin.data.converter.toMovie
|
||||
import hu.bbara.purefin.data.converter.toSeason
|
||||
@@ -11,6 +12,7 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -82,7 +84,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
seriesState.update { current ->
|
||||
val existing = current[series.id]
|
||||
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) {
|
||||
series.copy(seasons = existing.seasons)
|
||||
series.copy(
|
||||
seasons = existing.seasons,
|
||||
uncategorizedEpisodes = existing.uncategorizedEpisodes,
|
||||
)
|
||||
} else {
|
||||
series
|
||||
}
|
||||
@@ -124,7 +129,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
current + series.associateBy { it.id }.mapValues { (id, newSeries) ->
|
||||
val existing = current[id]
|
||||
if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) {
|
||||
newSeries.copy(seasons = existing.seasons)
|
||||
newSeries.copy(
|
||||
seasons = existing.seasons,
|
||||
uncategorizedEpisodes = existing.uncategorizedEpisodes,
|
||||
)
|
||||
} else {
|
||||
newSeries
|
||||
}
|
||||
@@ -145,8 +153,16 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
|
||||
}
|
||||
|
||||
val existingSeasonsById = series.seasons.associateBy { it.id }
|
||||
val updatedSeries = series.copy(
|
||||
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
||||
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }.map { fresh ->
|
||||
val existing = existingSeasonsById[fresh.id]
|
||||
if (existing != null && existing.episodes.isNotEmpty()) {
|
||||
fresh.copy(episodes = existing.episodes)
|
||||
} else {
|
||||
fresh
|
||||
}
|
||||
}
|
||||
)
|
||||
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
||||
} catch (error: CancellationException) {
|
||||
@@ -160,6 +176,11 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
|
||||
singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") {
|
||||
try {
|
||||
// The "Uncategorized" season is synthetic; its episodes are
|
||||
// populated as a side effect of loading real seasons, so
|
||||
// selecting that tab must not trigger a Jellyfin API call.
|
||||
if (seasonId == UNCATEGORIZED_SEASON_ID) return@run
|
||||
|
||||
// Ensure the series and season is loaded
|
||||
var series = seriesState.value[seriesId]
|
||||
if (series == null) {
|
||||
@@ -179,22 +200,35 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
||||
.map { it.toEpisode(serverUrl) }
|
||||
val seriesName = seriesState.value[seriesId]?.name
|
||||
val (categorized, uncategorized) = jellyfinApiClient
|
||||
.getEpisodesInSeason(seriesId, seasonId)
|
||||
.partition { !it.isUncategorizedEpisode() }
|
||||
val categorizedEpisodes = categorized.map {
|
||||
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
|
||||
}
|
||||
val uncategorizedEpisodes = uncategorized.map {
|
||||
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
|
||||
}
|
||||
seriesState.update { current ->
|
||||
val currentSeries = current[seriesId] ?: return@update current
|
||||
val updatedSeries = currentSeries.copy(
|
||||
seasons = currentSeries.seasons.map {
|
||||
if (it.id == seasonId && it.episodes.isEmpty()) {
|
||||
it.copy(episodes = episodes)
|
||||
it.copy(episodes = categorizedEpisodes)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
},
|
||||
uncategorizedEpisodes = (
|
||||
currentSeries.uncategorizedEpisodes + uncategorizedEpisodes
|
||||
).distinctBy { it.id }
|
||||
)
|
||||
current + (updatedSeries.id to updatedSeries)
|
||||
}
|
||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
||||
episodesState.update { current ->
|
||||
current + (categorizedEpisodes + uncategorizedEpisodes).associateBy { it.id }
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
@@ -277,8 +311,21 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
val uncategorizedEpisodes = if (
|
||||
series.uncategorizedEpisodes.none { it.id == updatedEpisode.id }
|
||||
) {
|
||||
series.uncategorizedEpisodes
|
||||
} else {
|
||||
changed = true
|
||||
series.uncategorizedEpisodes.map { episode ->
|
||||
if (episode.id == updatedEpisode.id) updatedEpisode else episode
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
current + (series.id to series.copy(seasons = seasons))
|
||||
current + (series.id to series.copy(
|
||||
seasons = seasons,
|
||||
uncategorizedEpisodes = uncategorizedEpisodes,
|
||||
))
|
||||
} else {
|
||||
current
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ 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 hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||
import hu.bbara.purefin.model.UNCATEGORIZED_SERIES_ID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun BaseItemDto.toLibrary(serverUrl: String): Library? {
|
||||
@@ -101,24 +104,38 @@ fun BaseItemDto.toSeason(): Season {
|
||||
)
|
||||
}
|
||||
|
||||
fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
||||
/**
|
||||
* An episode is considered "uncategorized" when it is missing any of the
|
||||
* identity/season fields required to place it under a real season. Missing
|
||||
* watch data ([userData]) is intentionally NOT a trigger — it is defaulted
|
||||
* so the episode can still appear under its real season.
|
||||
*/
|
||||
fun BaseItemDto.isUncategorizedEpisode(): Boolean =
|
||||
parentId == null || seriesId == null || seriesName == null ||
|
||||
parentIndexNumber == null || indexNumber == null
|
||||
|
||||
fun BaseItemDto.toEpisode(
|
||||
serverUrl: String,
|
||||
fallbackSeriesId: UUID? = null,
|
||||
fallbackSeriesName: String? = null,
|
||||
): Episode {
|
||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||
val imageUrlPrefix = id?.let { itemId ->
|
||||
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
|
||||
} ?: ""
|
||||
return Episode(
|
||||
id = id,
|
||||
seriesId = seriesId!!,
|
||||
seriesName = seriesName!!,
|
||||
seasonId = parentId!!,
|
||||
seasonIndex = parentIndexNumber!!,
|
||||
id = id ?: error("Episode without id"),
|
||||
seriesId = seriesId ?: fallbackSeriesId ?: UNCATEGORIZED_SERIES_ID,
|
||||
seriesName = seriesName ?: fallbackSeriesName ?: "Unknown",
|
||||
seasonId = parentId ?: UNCATEGORIZED_SEASON_ID,
|
||||
seasonIndex = parentIndexNumber ?: 0,
|
||||
title = name ?: "Unknown title",
|
||||
index = indexNumber!!,
|
||||
index = indexNumber ?: 0,
|
||||
releaseDate = releaseDate,
|
||||
rating = officialRating ?: "NR",
|
||||
runtime = formatRuntime(runTimeTicks),
|
||||
progress = userData!!.playedPercentage,
|
||||
watched = userData!!.played,
|
||||
progress = userData?.playedPercentage,
|
||||
watched = userData?.played ?: false,
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
synopsis = overview ?: "No synopsis available.",
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 hu.bbara.purefin.model.UNCATEGORIZED_LABEL
|
||||
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.UUID
|
||||
@@ -78,6 +80,23 @@ class OfflineRoomMediaLocalDataSource(
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
if (series.uncategorizedEpisodes.isNotEmpty()) {
|
||||
val unwatched = series.uncategorizedEpisodes.count { !it.watched }
|
||||
val sentinelSeason = Season(
|
||||
id = UNCATEGORIZED_SEASON_ID,
|
||||
seriesId = series.id,
|
||||
name = UNCATEGORIZED_LABEL,
|
||||
index = 0,
|
||||
unwatchedEpisodeCount = unwatched,
|
||||
episodeCount = series.uncategorizedEpisodes.size,
|
||||
episodes = series.uncategorizedEpisodes,
|
||||
)
|
||||
seasonDao.upsert(sentinelSeason.toEntity())
|
||||
series.uncategorizedEpisodes.forEach { episode ->
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +113,19 @@ class OfflineRoomMediaLocalDataSource(
|
||||
seriesDao.getById(episode.seriesId)
|
||||
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
||||
|
||||
if (episode.seasonId == UNCATEGORIZED_SEASON_ID && seasonDao.getById(UNCATEGORIZED_SEASON_ID) == null) {
|
||||
seasonDao.upsert(
|
||||
SeasonEntity(
|
||||
id = UNCATEGORIZED_SEASON_ID,
|
||||
seriesId = episode.seriesId,
|
||||
name = UNCATEGORIZED_LABEL,
|
||||
index = 0,
|
||||
unwatchedEpisodeCount = 0,
|
||||
episodeCount = 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
episodeDao.upsert(episode.toEntity())
|
||||
}
|
||||
}
|
||||
@@ -165,7 +197,9 @@ class OfflineRoomMediaLocalDataSource(
|
||||
}
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
||||
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
|
||||
return seasonDao.getBySeriesId(seriesId)
|
||||
.filter { it.id != UNCATEGORIZED_SEASON_ID }
|
||||
.map { seasonEntity ->
|
||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||
episodeEntity.toDomain()
|
||||
}
|
||||
@@ -277,7 +311,10 @@ class OfflineRoomMediaLocalDataSource(
|
||||
cast = emptyList()
|
||||
)
|
||||
|
||||
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series(
|
||||
private fun SeriesEntity.toDomain(seasons: List<Season>): Series {
|
||||
val (realSeasons, sentinelSeason) = seasons.partition { it.id != UNCATEGORIZED_SEASON_ID }
|
||||
val uncategorizedEpisodes = sentinelSeason.flatMap { it.episodes }
|
||||
return Series(
|
||||
id = id,
|
||||
libraryId = libraryId,
|
||||
name = name,
|
||||
@@ -286,9 +323,11 @@ class OfflineRoomMediaLocalDataSource(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||
seasonCount = seasonCount,
|
||||
seasons = seasons,
|
||||
cast = emptyList()
|
||||
seasons = realSeasons,
|
||||
uncategorizedEpisodes = uncategorizedEpisodes,
|
||||
cast = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
||||
id = id,
|
||||
|
||||
@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
purefinReleaseVersionCode=1000035
|
||||
purefinReleaseVersionCode=1000037
|
||||
purefinDebugVersionCode=1000003
|
||||
purefinTvVersionCode=2000022
|
||||
purefinTvVersionCode=2000023
|
||||
|
||||
Reference in New Issue
Block a user