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