Compare commits

...

56 Commits

Author SHA1 Message Date
660b50d7cd chore(build): increment app-tv versionCode to 2000023 2026-07-22 20:41:43 +02:00
7472b35d66 chore(build): increment app versionCode to 1000037 2026-07-22 20:39:20 +02:00
d8de27c972 feat(series): surface uncategorized episodes as synthetic season
Episodes missing season/identity fields from Jellyfin previously crashed
the converter due to non-null assertions. Introduce an Uncategorized
sentinel season appended to a series via Series.allSeasons so these
episodes are selectable in the season tabs on both phone and TV.

- Add UNCATEGORIZED_SEASON_ID/SERIES_ID/LABEL constants in core-model
- Make BaseItemDtoConverter null-safe and partition uncategorized episodes
- InMemoryLocalMediaRepository collects uncategorized episodes per series
- OfflineRoomMediaLocalDataSource persists and reconstructs the sentinel
- SeriesScreen and TvSeriesScreen use allSeasons for tab rendering
2026-07-22 18:36:02 +00:00
e33a92271a chore(build): increment app versionCode to 1000036 2026-07-21 19:06:37 +02:00
869bb27497 feat(player): add sticky auto-brightness zone with hysteresis
Refactor brightness drag to use a normal 0-1 sliding range with a
sticky auto-brightness zone at the bottom. Enter/exit auto mode via
hysteresis thresholds (15% overshoot past 0%) so accidental drags
don't toggle the mode.

Lock the current system brightness on screen open (reading the actual
value even when in auto mode) and restore the original window brightness
on ON_STOP.

Add vertical drag lifecycle callbacks (start/end) to PlayerGesturesLayer
so overshoot can be reset per gesture. Extract drag handlers into local
functions for readability.
2026-07-21 17:01:35 +00:00
a4bb57822a fix(catalog): preserve cached episodes when refreshing seasons 2026-07-21 16:46:33 +00:00
2d649cdff9 chore(build): increment app versionCode to 1000035 2026-07-18 20:49:48 +02:00
33c83bd30f chore(build): increment app-tv versionCode to 2000022 2026-07-18 20:44:08 +02:00
37bf64f5f8 fix(player): adjust subtitle bottom padding when controls visible
Increase TV controls-visible subtitle bottom padding fraction from 0.22 to 0.31 and update PlayerScreen to reapply player and subtitle padding on recomposition.
2026-07-18 18:43:38 +00:00
e6da94970a fix(build): downgrade targetSdk from 37 to 36 for app and app-tv 2026-07-18 20:31:45 +02:00
28b647e209 build(deps): upgrade gradle, agp, kotlin, compose and sdk to 37
- Bump compileSdk/targetSdk from 36 to 37 across all modules
- Upgrade gradle wrapper to 9.6.1
- Upgrade agp to 9.3.0, kotlin to 2.4.10, ksp to 2.3.10
- Upgrade composeBom to 2026.06.01 and compose libs
- Upgrade hilt to 2.60.1, room to 2.8.4, media3 to 1.10.1
- Upgrade okhttp to 5.4.0, coil to 3.5.0, datastore to 1.2.1
- Upgrade lifecycle to 2.11.0, activity-compose to 1.13.0
- Upgrade firebase crashlytics to 20.1.0, gms services to 4.5.0
2026-07-18 15:11:38 +00:00
b6ebe3d427 fix(app-tv): correct foundation dependency to androidx.compose.foundation 2026-07-18 14:17:31 +00:00
76d75221cd feat(core): add media3-cast and media3-session dependencies 2026-07-18 14:17:28 +00:00
6b9724c640 chore(build): add media3-cast and media3-session catalog entries, remove unused foundation 2026-07-18 14:17:25 +00:00
c40df41603 chore(build): increment app-tv versionCode to 2000021 2026-07-16 17:51:19 +02:00
0feb16371e chore(build): increment app versionCode to 1000034 2026-07-13 20:32:59 +02:00
ced9ca7b9d feat(player): refactor back navigation handling in TvPlayerScreen 2026-07-13 20:27:05 +02:00
79066b2ce5 feat(player): load external subtitles including ASS/SSA via SubtitleConfiguration 2026-07-11 15:49:56 +02:00
dfab094782 chore(player): remove unused constant for controls auto-hide duration 2026-07-11 13:16:24 +02:00
26d5e7fdcb chore(graphify): update knowledge graph 2026-07-11 12:40:28 +02:00
5720d4e45b feat(player): persist offline media progress periodically during playback
Add a periodic call to updateWatchProgress in the 5-second progress loop when playing offline media (playbackReportContext == null).

Previously, offline media progress was only written to the Room database on session stop/release. This meant that if playback was interrupted (crash, force-kill) the persisted position would be lost.

Now, the Room offline cache is updated every 5 seconds during offline playback, matching the existing scrobble cadence for online streaming.
2026-07-09 16:12:40 +00:00
f4d2fdd3f7 fix(player): persist previous item progress before updating position snapshot
Reorder the position/duration snapshot in ProgressManager.bind() so
stopSession() reads the previous item's last-known position instead
of the next item's seek target. Fixes the offline Room database
being written with 0% progress on item transitions.
2026-07-05 08:02:28 +00:00
a2121afc2d chore(graphify): add knowledge graph output for codebase exploration
Includes interactive graph visualization (graph.html), audit report
(GRAPH_REPORT.md), raw graph data (graph.json), and extraction
caches built from 349 source files (2741 nodes, 4999 edges).
2026-07-04 09:19:44 +00:00
4879eae9bb chore(build): increment app-tv versionCode to 2000020 2026-06-30 22:45:10 +02:00
b28637be4f refactor(player): switch from onPreviewKeyEvent to onKeyEvent
Use onKeyEvent so BackHandler gets priority for back key handling before the composable processes other key events.
2026-06-30 20:44:14 +00:00
b2db0c2553 chore(build): increment app-tv versionCode to 2000019 2026-06-30 22:16:02 +02:00
7f68b9d32b refactor(player): move back key handling to BackHandler in TvPlayerScreen
Consolidate Back and Escape key handling in the existing BackHandler
composable and remove the redundant branch from
handleTvPlayerRootKeyEvent. The BackHandler already implements the same
panel/playlist/controls dismissal chain and adds the missing onBack()
fallback for system navigation. Drop the now-unused parameters
(isPlaylistExpanded, trackPanelType, onCloseTrackPanel,
onCollapsePlaylist, onHideControls) from both the function and its
call site.
2026-06-30 20:15:26 +00:00
cd3fa8ace1 Revert "fix(player): prevent Back key from triggering player controls"
This reverts commit 3dc757f0f7.
2026-06-30 21:59:33 +02:00
bb61db17bd chore(build): increment app-tv versionCode to 2000018 2026-06-30 21:02:07 +02:00
3dc757f0f7 fix(player): prevent Back key from triggering player controls
Remove Key.Back from the root key event handler in TvPlayerScreen so that only Key.Escape closes the track panel or pauses playback. The Back key should be handled by system navigation instead.
2026-06-30 19:00:52 +00:00
f73ca4e242 feat(series): add focus indication with border to episode cards 2026-06-30 20:58:39 +02:00
f25f0e0827 feat(player): add lifecycle event effect to hide controls on start 2026-06-30 19:58:48 +02:00
8d194f2baf chore(build): increment app versionCode to 1000033 2026-06-26 18:40:24 +02:00
c836c809fc feat(series): add confirmation dialog before marking series as watched 2026-06-26 16:39:37 +00:00
b6fc10df57 chore(build): increment app-tv versionCode to 2000017 2026-06-26 18:34:43 +02:00
18e68c6051 feat(series): add long-click context menu to episode cards
Add a ModalBottomSheet with Mark as watched/unwatched actions on long-click of episode cards.

- Replace clickable with combinedClickable to support long-click gestures
- Add markEpisodeAsWatched function to SeriesViewModel
- Add MediaAction model usage for bottom sheet actions
- Add test tag for the dialog component
2026-06-26 16:32:53 +00:00
e8224849cc fix(player): prevent hidden seek timeline from showing when controls are visible 2026-06-26 18:29:05 +02:00
c2cc4e9e65 revert(data): remove ETag caching and head-fetch home refresh
Reverts 5491c38 (ETag-based conditional HTTP caching for home refresh) and 1ec9ff9 (head-fetch rows and count-only libraries). Restores the pre-ETag home refresh path that fully loads library content per library and fetches each home row in a single request.

Preserves unrelated later improvements: SingleFlight deduplication (86f44ac), nullable toLibrary skip for unsupported library types (80b1bd8), and upsert-before-publish ordering to prevent empty intermediate states (f0a7828).
2026-06-26 15:44:05 +00:00
13bc9410b4 chore(build): increment app-tv versionCode to 2000016 2026-06-25 21:17:10 +02:00
422642ad19 chore(build): increment app versionCode to 1000032 2026-06-25 21:14:33 +02:00
59315b701d fix(repository): merge existing seasons when updating series with empty seasons 2026-06-25 21:09:43 +02:00
3c46650a63 fix(repository): enhance series upsert logic to preserve existing seasons 2026-06-25 20:47:38 +02:00
f0a7828613 refactor(repository): reorder state updates to prevent empty intermediate states 2026-06-22 20:30:31 +02:00
8c758e8262 feat(tv-player): add live clock overlay to player controls 2026-06-22 18:27:09 +00:00
9872f401f8 refactor(data): remove redundant dedup guards and improve loading robustness 2026-06-22 18:09:50 +00:00
86f44ac9ce refactor(data): deduplicate concurrent data fetches with SingleFlight 2026-06-22 17:26:50 +00:00
d80b283ded fix(repository): streamline season loading and update series state 2026-06-22 19:20:20 +02:00
1ac649e8ae fix(tv-player): remove unused seek live edge functionality and related UI elements 2026-06-22 18:41:23 +02:00
b23db4bab3 fix(player): keep seek bar thumb at target on release
Previously, the Slider's value was switched from the local sliderPosition to
the positionMs prop the instant onValueChangeFinished cleared isScrubbing.
Because the prop had not yet caught up with the committed seek, the thumb
visibly snapped back to the pre-seek position before jumping to the target.

Drive the slider from sliderPosition unconditionally, sync sliderPosition
from the prop via a LaunchedEffect only when not scrubbing, and pin
sliderPosition to the committed target before clearing isScrubbing in
onValueChangeFinished.
2026-06-21 11:17:16 +00:00
67a141e7d7 chore(build): increment app versionCode to 1000031 2026-06-21 10:25:20 +02:00
9f432edd27 chore(build): increment app-tv versionCode to 2000015 2026-06-20 21:50:31 +02:00
80b1bd8547 fix(data): gracefully skip unsupported library types during refresh
Previously, toLibrary() threw UnsupportedOperationException for
library types the app does not surface (e.g. boxsets, music, photos,
live TV), which aborted the entire home screen refresh. Now these
unsupported types are logged and skipped, preventing a single
unexpected view from leaving the user with an empty home screen.
2026-06-20 21:50:15 +02:00
00c5689195 fix(tv-player): Do not show hidden seek line when skip button is shown 2026-06-20 21:12:13 +02:00
bd2c1777a5 fix(tv-player): Only show paused feedback 2026-06-20 21:12:12 +02:00
a5d04998ac refactor(tv): extract hero backdrop and simplify gradient drawing
Split the hero backdrop (image + gradient overlays) into TvHomeHeroBackdrop, rendered behind the home content. Replace nested Box/background gradient approach with drawWithContent for better composability. Clean up unused imports across TvHomeScreen, TvFocusedItemHero, and TvHomeContent.
2026-06-20 18:52:09 +00:00
da89a8a661 fix(tv): adjust hero height fraction for better layout on TV screens 2026-06-20 20:22:49 +02:00
54 changed files with 85188 additions and 1123 deletions

View File

@@ -13,7 +13,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.tv" namespace = "hu.bbara.purefin.tv"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "hu.bbara.purefin.tv" applicationId = "hu.bbara.purefin.tv"
@@ -59,7 +59,7 @@ dependencies {
implementation(project(":core-ui")) implementation(project(":core-ui"))
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.foundation) implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)

View File

@@ -1,6 +1,6 @@
package hu.bbara.purefin.ui.screen.home package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -10,11 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -30,11 +31,13 @@ fun TvHomeScreen(
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) } val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize() color = scheme.background
.background(scheme.background)
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
TvHomeHeroBackdrop(
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
)
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
@@ -56,4 +59,5 @@ fun TvHomeScreen(
) )
} }
} }
}
} }

View File

@@ -2,9 +2,7 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -16,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -31,40 +30,34 @@ internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label" internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180 private const val TvHomeHeroAnimationMillis = 180
// Half the screen for the billboard, half for the content rows. Tuned so both // The billboard backdrop covers the top portion of the home screen so the hero
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV. // text sits at the bottom of the backdrop and the content rows begin below it.
internal const val TvHomeHeroHeightFraction = 0.5f internal const val TvHomeHeroHeightFraction = 0.45f
internal const val TvHomeBackdropOffset = 0.20f
/**
* The home screen background image plus the darkening gradients that keep the
* hero text readable. Drawn behind the home content and sized to the top
* [TvHomeHeroHeightFraction] of the screen.
*/
@Composable @Composable
internal fun TvFocusedItemHero( internal fun TvHomeHeroBackdrop(
item: MediaUiModel, backdropImageUrl: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction, heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box( Crossfade(
targetState = backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-backdrop",
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(heightFraction) .fillMaxHeight(heightFraction)
.background(scheme.background) .drawWithContent {
) { drawContent()
Crossfade(
targetState = item.backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// Darken the left side so the text block stays readable over any backdrop. // Darken the left side so the text block stays readable over any backdrop.
Box( drawRect(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient( Brush.horizontalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.86f), 0.0f to scheme.background.copy(alpha = 0.86f),
@@ -74,12 +67,8 @@ internal fun TvFocusedItemHero(
) )
) )
) )
)
// Strong bottom fade so the title and metadata are legible. // Strong bottom fade so the title and metadata are legible.
Box( drawRect(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient( Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f), 0.0f to scheme.background.copy(alpha = 0f),
@@ -89,11 +78,31 @@ internal fun TvFocusedItemHero(
) )
) )
) )
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
}
}
@Composable
internal fun TvFocusedItemHero(
item: MediaUiModel,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) {
val scheme = MaterialTheme.colorScheme
Crossfade( Crossfade(
targetState = item, targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis), animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content" label = "tv-home-hero-content",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
) { hero -> ) { hero ->
Column( Column(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.Bottom,
@@ -129,7 +138,6 @@ internal fun TvFocusedItemHero(
} }
} }
} }
}
} }
@Composable @Composable

View File

@@ -2,7 +2,6 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -42,7 +40,6 @@ fun TvHomeContent(
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp), contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() } val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty() val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty() val hasNextUp = nextUp.isNotEmpty()
@@ -65,7 +62,6 @@ fun TvHomeContent(
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(scheme.background)
.focusRequester(initialFocusRequester), .focusRequester(initialFocusRequester),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = contentPadding contentPadding = contentPadding

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity import android.app.Activity
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.BackHandler
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
@@ -23,10 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -46,10 +45,11 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -75,9 +75,12 @@ import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback" internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@@ -161,6 +164,9 @@ fun TvPlayerScreen(
rootFocusRequester.requestFocus() rootFocusRequester.requestFocus()
} }
} }
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
fun expandPlaylist() { fun expandPlaylist() {
// TODO: focus management for playlist expansion // TODO: focus management for playlist expansion
@@ -176,6 +182,14 @@ fun TvPlayerScreen(
trackPanelType = null trackPanelType = null
} }
} }
fun handleBack() {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> closePlaylist()
controlsVisible -> hideControls()
else -> onBack()
}
}
val showSkipIntroButton = !controlsVisible val showSkipIntroButton = !controlsVisible
@@ -223,42 +237,28 @@ fun TvPlayerScreen(
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
} }
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> {
closePlaylist()
}
controlsVisible -> {
hideControls()
}
else -> onBack()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(rootFocusRequester) .focusRequester(rootFocusRequester)
.onPreviewKeyEvent { event -> .onKeyEvent { event ->
if (event.key == Key.Back) {
if (event.type == KeyEventType.KeyDown) {
handleBack()
}
true
} else {
val handled = handleTvPlayerRootKeyEvent( val handled = handleTvPlayerRootKeyEvent(
event = event, event = event,
controlsVisible = controlsVisible, controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay, popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls, onShowControls = ::showControls,
isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = {},
onHideControls = ::hideControls,
onTogglePlayback = { onTogglePlayback = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden resume/stop feedback.
resumeStopFeedbackCounter++ resumeStopFeedbackCounter++
viewModel.togglePlayPause() viewModel.togglePlayPause()
}, },
onSeekRelative = { onSeekRelative = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden seek timeline.
hiddenSeekCounter++ hiddenSeekCounter++
viewModel.seekBy(it) viewModel.seekBy(it)
}, },
@@ -268,6 +268,7 @@ fun TvPlayerScreen(
} }
handled handled
} }
}
.focusable() .focusable()
) { ) {
AndroidView( AndroidView(
@@ -277,6 +278,8 @@ fun TvPlayerScreen(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction) subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
isFocusable = false
isFocusableInTouchMode = false
} }
}, },
update = { update = {
@@ -308,9 +311,6 @@ fun TvPlayerScreen(
onSeekRelative = { deltaMs -> onSeekRelative = { deltaMs ->
viewModel.seekBy(deltaMs) viewModel.seekBy(deltaMs)
}, },
onSeekLiveEdge = {
viewModel.seekToLiveEdge()
},
onSkipSegment = { onSkipSegment = {
viewModel.skipActiveSegment() viewModel.skipActiveSegment()
}, },
@@ -330,6 +330,17 @@ fun TvPlayerScreen(
) )
} }
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
TvPlayerClock()
}
AnimatedVisibility( AnimatedVisibility(
visible = showSkipIntroButton, visible = showSkipIntroButton,
modifier = Modifier modifier = Modifier
@@ -367,6 +378,7 @@ fun TvPlayerScreen(
} }
} }
if (!showSkipIntroButton && !controlsVisible) {
ValueChangeTimedVisibility( ValueChangeTimedVisibility(
value = hiddenSeekCounter, value = hiddenSeekCounter,
hideAfterMillis = 2500L, hideAfterMillis = 2500L,
@@ -382,6 +394,7 @@ fun TvPlayerScreen(
adMarkers = uiState.ads adMarkers = uiState.ads
) )
} }
}
TvPlayerLoadingErrorEndCard( TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
@@ -400,7 +413,7 @@ fun TvPlayerScreen(
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS, hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) { ) {
TvPlayerResumeStopFeedback(resume = uiState.isPlaying) TvPlayerStopFeedback(stopped = !uiState.isPlaying)
} }
AnimatedVisibility( AnimatedVisibility(
@@ -464,42 +477,35 @@ private fun HiddenTvSeekTimeline(
} }
} }
@Composable
private fun TvPlayerClock(modifier: Modifier = Modifier) {
var currentTime by remember { mutableStateOf(LocalTime.now()) }
LaunchedEffect(Unit) {
while (true) {
currentTime = LocalTime.now()
delay(1_000L)
}
}
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
Text(
text = formatter.format(currentTime),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
internal fun handleTvPlayerRootKeyEvent( internal fun handleTvPlayerRootKeyEvent(
event: KeyEvent, event: KeyEvent,
controlsVisible: Boolean, controlsVisible: Boolean,
popupVisible: Boolean, popupVisible: Boolean,
isPlaylistExpanded: Boolean,
trackPanelType: TvTrackPanelType?,
onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit,
onTogglePlayback: () -> Unit, onTogglePlayback: () -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit, onShowControls: () -> Unit,
): Boolean { ): Boolean {
if (event.type != KeyEventType.KeyDown) return false if (event.type != KeyEventType.KeyDown) return false
if (event.key == Key.Back || event.key == Key.Escape) {
return when {
trackPanelType != null -> {
onCloseTrackPanel()
true
}
isPlaylistExpanded -> {
onCollapsePlaylist()
true
}
controlsVisible -> {
onHideControls()
true
}
else -> false
}
}
if (!controlsVisible) { if (!controlsVisible) {
return when (event.key) { return when (event.key) {
Key.DirectionLeft -> { Key.DirectionLeft -> {
@@ -535,10 +541,13 @@ internal fun handleTvPlayerRootKeyEvent(
} }
@Composable @Composable
internal fun TvPlayerResumeStopFeedback( internal fun TvPlayerStopFeedback(
resume: Boolean, stopped: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (!stopped) {
return
}
Box( Box(
modifier = modifier modifier = modifier
.testTag(TvPlayerHiddenStopFeedbackTag) .testTag(TvPlayerHiddenStopFeedbackTag)
@@ -548,7 +557,7 @@ internal fun TvPlayerResumeStopFeedback(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = if (resume) Icons.Outlined.PlayArrow else Icons.Outlined.Pause, imageVector = Icons.Outlined.Pause,
contentDescription = "Play/Pause playback", contentDescription = "Play/Pause playback",
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(72.dp) modifier = Modifier.size(72.dp)

View File

@@ -16,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Replay10
import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -63,7 +61,6 @@ internal fun TvPlayerControlsOverlay(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit, onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
@@ -109,7 +106,6 @@ internal fun TvPlayerControlsOverlay(
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment, onSkipSegment = onSkipSegment,
onNext = onNext, onNext = onNext,
onPrevious = onPrevious, onPrevious = onPrevious,
@@ -172,7 +168,6 @@ private fun TvPlayerBottomSection(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit, onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
@@ -247,13 +242,6 @@ private fun TvPlayerBottomSection(
size = 56, size = 56,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
) )
TvIconButton(
icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) },
size = 56,
modifier = expandPlaylistModifier
)
TvIconButton( TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play", contentDescription = if (uiState.isPlaying) "Pause" else "Play",
@@ -263,13 +251,6 @@ private fun TvPlayerBottomSection(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.testTag(TvPlayerPlayPauseButtonTag) .testTag(TvPlayerPlayPauseButtonTag)
) )
TvIconButton(
icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) },
size = 56,
modifier = expandPlaylistModifier
)
TvIconButton( TvIconButton(
icon = Icons.Outlined.SkipNext, icon = Icons.Outlined.SkipNext,
contentDescription = "Next", contentDescription = "Next",

View File

@@ -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? {

View File

@@ -153,14 +153,20 @@ private fun TvSeasonTab(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val color = if (isSelected) scheme.primary else scheme.onSurface val color = if (isSelected) scheme.primary else scheme.onSurface
val indicatorColor = if (isSelected) scheme.primary else Color.Transparent val isFocused = remember { mutableStateOf(false) }
Column( Box(
modifier = modifier modifier = modifier
.onFocusChanged { state -> .onFocusChanged { state ->
if (state.isFocused) onFocused() if (state.isFocused) {
onFocused()
isFocused.value = true
} else {
isFocused.value = false
}
} }
.clickable(onClick = onClick) .clickable(onClick = onClick)
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
) { ) {
TvText( TvText(
text = name, text = name,
@@ -169,13 +175,8 @@ private fun TvSeasonTab(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp)
.height(2.dp)
.background(indicatorColor)
) )
} }
} }
@@ -256,11 +257,9 @@ internal fun TvEpisodeCarousel(
Modifier Modifier
} }
) )
.then( .then(upFocusRequester?.let { requester ->
upFocusRequester?.let { requester ->
Modifier.focusProperties { up = requester } Modifier.focusProperties { up = requester }
} ?: Modifier } ?: Modifier)
)
.then( .then(
if (episode.id == targetEpisodeId) { if (episode.id == targetEpisodeId) {
Modifier.testTag(SeriesNextUpEpisodeCardTag) Modifier.testTag(SeriesNextUpEpisodeCardTag)
@@ -432,3 +431,15 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
// roleSize = 10.sp // roleSize = 10.sp
// ) // )
} }
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
if (isFocused) {
this.border(
width = 2.dp,
color = borderColor,
shape = RoundedCornerShape(12.dp)
)
} else {
this
}

View File

@@ -11,7 +11,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin" namespace = "hu.bbara.purefin"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "hu.bbara.purefin" applicationId = "hu.bbara.purefin"

View File

@@ -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
@@ -37,6 +38,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
@@ -65,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)
@@ -88,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) }
@@ -129,6 +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) {
hideControlsWithTimeout()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
restoreBrightness(activity, originalScreenBrightness)
}
Box( Box(
@@ -145,6 +208,10 @@ fun PlayerScreen(
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction) subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
} }
}, },
update = {
it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
},
onRelease = { playerView -> onRelease = { playerView ->
playerView.player = null playerView.player = null
}, },
@@ -160,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 {
@@ -221,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()}%"
} }
) )
} }
@@ -425,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
} }

View File

@@ -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()
}
} }
} }
) )

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -33,7 +34,16 @@ fun PlayerSeekBar(
val currentPosition = positionMs.coerceIn(0, safeDuration) val currentPosition = positionMs.coerceIn(0, safeDuration)
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) } var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
var isScrubbing by remember { mutableStateOf(false) } var isScrubbing by remember { mutableStateOf(false) }
val sliderValue = if (isScrubbing) sliderPosition else currentPosition.toFloat() val sliderValue = sliderPosition
// Keep sliderPosition in sync with the player's position when the user is not
// actively scrubbing. While scrubbing, the local drag value is preserved so the
// thumb does not snap back to a stale positionMs prop before the seek is committed.
LaunchedEffect(positionMs) {
if (!isScrubbing) {
sliderPosition = positionMs.toFloat()
}
}
Box( Box(
modifier = modifier modifier = modifier
@@ -64,6 +74,7 @@ fun PlayerSeekBar(
}, },
onValueChangeFinished = { onValueChangeFinished = {
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration) val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
sliderPosition = targetPosition.toFloat()
isScrubbing = false isScrubbing = false
onSeek(targetPosition) onSeek(targetPosition)
}, },

View File

@@ -3,9 +3,11 @@ package hu.bbara.purefin.ui.screen.series
import android.content.Intent import android.content.Intent
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -144,14 +146,15 @@ private fun SeriesScreenInternal(
val context = LocalContext.current val context = LocalContext.current
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) } var showDownloadDialog 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) {
@@ -188,7 +191,13 @@ private fun SeriesScreenInternal(
nextUpEpisode = nextUpEpisode, nextUpEpisode = nextUpEpisode,
onPlayClick = playAction ?: {}, onPlayClick = playAction ?: {},
onDownloadClick = { showDownloadDialog = true }, onDownloadClick = { showDownloadDialog = true },
onMarkAsWatched = onMarkAsWatched, onMarkAsWatched = { watched ->
if (watched) {
showMarkAsWatchedDialog = true
} else {
onMarkAsWatched(false)
}
},
bodyColor = scheme.onSurface bodyColor = scheme.onSurface
), ),
modifier = modifier, modifier = modifier,
@@ -201,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
@@ -233,6 +242,16 @@ private fun SeriesScreenInternal(
onDismiss = { showDownloadDialog = false } onDismiss = { showDownloadDialog = false }
) )
} }
if (showMarkAsWatchedDialog) {
MarkSeriesAsWatchedConfirmationDialog(
onConfirm = {
showMarkAsWatchedDialog = false
onMarkAsWatched(true)
},
onDismiss = { showMarkAsWatchedDialog = false }
)
}
} }
private fun Series.toMediaDetailScaffoldUiModel( private fun Series.toMediaDetailScaffoldUiModel(
@@ -312,3 +331,25 @@ private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = whe
SeriesDownloadOption.SMART -> true SeriesDownloadOption.SMART -> true
SeriesDownloadOption.DELETE_SMART -> false SeriesDownloadOption.DELETE_SMART -> false
} }
@Composable
private fun MarkSeriesAsWatchedConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Mark series as watched?") },
text = { Text("This will mark every episode of this series as watched.") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Mark as watched")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -1,8 +1,10 @@
package hu.bbara.purefin.ui.screen.series.components package hu.bbara.purefin.ui.screen.series.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -60,6 +62,7 @@ import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.GhostIconButton import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -315,6 +318,7 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
} }
} }
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
private fun EpisodeCard( private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
@@ -322,15 +326,31 @@ private fun EpisodeCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
var showBottomSheet by remember { mutableStateOf(false) }
val popupActions = remember(episode.id) {
listOf(
MediaAction(name = "Mark as watched") {
viewModel.markEpisodeAsWatched(episode.id, true)
},
MediaAction(name = "Mark as unwatched") {
viewModel.markEpisodeAsWatched(episode.id, false)
}
)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}") .testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
.clickable { viewModel.onSelectEpisode( .combinedClickable(
onClick = {
viewModel.onSelectEpisode(
seriesId = episode.seriesId, seriesId = episode.seriesId,
seasonId = episode.seasonId, seasonId = episode.seasonId,
episodeId = episode.id episodeId = episode.id
) }, )
},
onLongClick = { showBottomSheet = true }
),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -389,6 +409,30 @@ private fun EpisodeCard(
) )
} }
} }
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
modifier = Modifier.testTag(SeriesEpisodeActionsDialogTag),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
popupActions.forEach { action ->
ListItem(
headlineContent = { Text(text = action.name) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable {
action.onClick()
showBottomSheet = false
}
)
}
}
}
}
} }
internal const val SeriesPlayButtonTag = "series-play-button" internal const val SeriesPlayButtonTag = "series-play-button"
@@ -402,6 +446,7 @@ internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
internal const val SeriesSeasonSelectorTag = "series-season-selector" internal const val SeriesSeasonSelectorTag = "series-season-selector"
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel" internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-" internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
internal const val SeriesEpisodeActionsDialogTag = "series-episode-actions-dialog"
@Composable @Composable
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) { internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {

View File

@@ -6,7 +6,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.model" namespace = "hu.bbara.purefin.model"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
minSdk = 29 minSdk = 29

View File

@@ -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,
)
}
}

View File

@@ -8,7 +8,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.core.ui" namespace = "hu.bbara.purefin.core.ui"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
minSdk = 29 minSdk = 29

View File

@@ -10,7 +10,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.core.download" namespace = "hu.bbara.purefin.core.download"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
minSdk = 29 minSdk = 29
@@ -39,6 +39,8 @@ dependencies {
implementation(libs.medi3.exoplayer) implementation(libs.medi3.exoplayer)
implementation(libs.medi3.exoplayer.hls) implementation(libs.medi3.exoplayer.hls)
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.media3.exoplayer.cast)
implementation(libs.media3.exoplayer.session)
implementation(libs.datastore) implementation(libs.datastore)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.timber) implementation(libs.timber)

View File

@@ -0,0 +1,97 @@
package hu.bbara.purefin.core.concurrency
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Coalesces concurrent and near-time-repeated suspend calls keyed by [key].
*
* Within a single [run] with a given [key]:
* - If a call with the same [key] is already in flight, the new caller
* awaits the same [Deferred] and receives the first caller's result.
* - If a call with the same [key] completed successfully less than
* [TTL_MS] ago, the cached result is returned without re-executing
* [block].
* - Otherwise [block] is executed, its result is cached for [TTL_MS]
* and shared with any concurrent awaiters.
*
* Failures are never cached: a thrown exception (other than
* [CancellationException]) is propagated to all awaiters of the
* in-flight call, but a subsequent call with the same key will execute
* [block] again immediately.
*
* [CancellationException] is always re-thrown so coroutine cancellation
* semantics are preserved.
*/
@Singleton
class SingleFlight @Inject constructor() {
private val inFlight: MutableMap<String, Deferred<*>> = ConcurrentHashMap()
private val cache: MutableMap<String, CacheEntry<*>> = ConcurrentHashMap()
private val mutex = Mutex()
suspend fun <T> run(key: String, block: suspend () -> T): T {
val now = System.currentTimeMillis()
@Suppress("UNCHECKED_CAST")
val cached = cache[key] as? CacheEntry<T>
if (cached != null && now - cached.timestamp < TTL_MS) {
return cached.value
}
val resolution: Resolution<T> = mutex.withLock {
@Suppress("UNCHECKED_CAST")
val cachedNow = cache[key] as? CacheEntry<T>
if (cachedNow != null && System.currentTimeMillis() - cachedNow.timestamp < TTL_MS) {
return@withLock Resolution.Cached(cachedNow.value)
}
@Suppress("UNCHECKED_CAST")
val existing = inFlight[key] as? Deferred<T>
if (existing != null) {
return@withLock Resolution.Join(existing)
}
val newDeferred = CompletableDeferred<T>()
inFlight[key] = newDeferred
Resolution.Start(newDeferred)
}
return when (resolution) {
is Resolution.Cached -> resolution.value
is Resolution.Join -> resolution.deferred.await()
is Resolution.Start -> {
val deferred = resolution.deferred
try {
val result = block()
cache[key] = CacheEntry(result, System.currentTimeMillis())
deferred.complete(result)
result
} catch (error: CancellationException) {
deferred.completeExceptionally(error)
throw error
} catch (error: Throwable) {
deferred.completeExceptionally(error)
throw error
} finally {
inFlight.remove(key, deferred)
}
}
}
}
private sealed class Resolution<T> {
data class Cached<T>(val value: T) : Resolution<T>()
data class Join<T>(val deferred: Deferred<T>) : Resolution<T>()
data class Start<T>(val deferred: CompletableDeferred<T>) : Resolution<T>()
}
private data class CacheEntry<T>(val value: T, val timestamp: Long)
companion object {
const val TTL_MS: Long = 15_000L
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.core.data package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.model.Library import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.Media import hu.bbara.purefin.model.Media
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -14,15 +13,4 @@ interface HomeRepository {
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
fun ensureReady() fun ensureReady()
suspend fun refreshHomeData() suspend fun refreshHomeData()
/**
* Fetches the full content (movies or series) of a single library on
* demand. Used by the library detail screen; the home page no longer
* pays for full library content on every refresh.
*
* Returns `null` when the server returned 304 Not Modified for the
* per-library ETag — the caller should keep its previously fetched
* copy. An empty list is a legitimate "the library is empty" result.
*/
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
} }

View File

@@ -68,17 +68,16 @@ class AppViewModel @Inject constructor(
val libraries = homeRepository.libraries.map { libraries -> val libraries = homeRepository.libraries.map { libraries ->
libraries.map { libraries.map {
// The home page no longer fetches the full library content
// (see InMemoryAppContentRepository.loadLibraries). isEmpty
// therefore reflects the actual server-reported size, not
// the contents of the local media repository.
LibraryUiModel( LibraryUiModel(
id = it.id, id = it.id,
name = it.name, name = it.name,
type = it.type, type = it.type,
posterUrl = it.posterUrl, posterUrl = it.posterUrl,
size = it.size, size = it.size,
isEmpty = it.size == 0, isEmpty = when (it.type) {
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
}
) )
} }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

View File

@@ -6,13 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel
import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.LibraryKind
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.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -26,35 +31,24 @@ class LibraryViewModel @Inject constructor(
private val selectedLibrary = MutableStateFlow<UUID?>(null) private val selectedLibrary = MutableStateFlow<UUID?>(null)
// Local cache of the last-fetched content per library. Used to preserve val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
// content when re-selecting a library whose ETag matches (so the libraryId, libraries ->
// repository's 304 response can be treated as "use the cached copy"). if (libraryId == null) {
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>() return@combine emptyList()
}
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList()) val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow() when (library.type) {
LibraryKind.SERIES -> library.series!!.map { series ->
SeriesUiModel(series)
}
LibraryKind.MOVIES -> library.movies!!.map { movie ->
MovieUiModel(movie)
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init { init {
viewModelScope.launch { homeRepository.ensureReady() } viewModelScope.launch { homeRepository.ensureReady() }
viewModelScope.launch {
selectedLibrary.collect { libraryId ->
if (libraryId == null) {
_contents.value = emptyList()
} else {
val fresh = homeRepository.loadLibraryContent(libraryId)
if (fresh != null) {
cachedContents[libraryId] = fresh
_contents.value = fresh
} else {
// ETag 304 — keep the cached copy for this library if
// we have one. On a first-visit 304 (rare, would
// require the library's ETag to be set without any
// prior fetch) the screen briefly shows empty.
_contents.value = cachedContents[libraryId] ?: emptyList()
}
}
}
}
} }
fun onMovieSelected(movieId: UUID) { fun onMovieSelected(movieId: UUID) {

View File

@@ -186,6 +186,12 @@ class SeriesViewModel @Inject constructor(
} }
} }
fun markEpisodeAsWatched(episodeId: UUID, watched: Boolean) {
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(episodeId, watched)
}
}
fun selectSeries(series: SeriesDto) { fun selectSeries(series: SeriesDto) {
_series.value = series _series.value = series
viewModelScope.launch { viewModelScope.launch {

View File

@@ -1,13 +0,0 @@
package hu.bbara.purefin.core.jellyfin
import javax.inject.Qualifier
/**
* Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`.
* Distinct from the unqualified client used for image loading and media streaming
* because the SDK has its own auth path and the ETag interceptor should not leak
* into non-SDK HTTP calls.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class JellyfinSdkClient

View File

@@ -43,8 +43,6 @@ class ProgressManager @Inject constructor(
combine(playbackState, progress, metadata) { state, prog, meta -> combine(playbackState, progress, metadata) { state, prog, meta ->
Triple(state, prog, meta) Triple(state, prog, meta)
}.collect { (state, prog, meta) -> }.collect { (state, prog, meta) ->
lastPositionMs = prog.positionMs
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val nextPlaybackReportContext = meta.playbackReportContext val nextPlaybackReportContext = meta.playbackReportContext
@@ -53,6 +51,9 @@ class ProgressManager @Inject constructor(
stopSession() stopSession()
} }
lastPositionMs = prog.positionMs
lastDurationMs = prog.durationMs
if (activeItemId == null && mediaId != null && !state.isEnded) { if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs, nextPlaybackReportContext) startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
} else if (activeItemId == mediaId) { } else if (activeItemId == mediaId) {
@@ -65,11 +66,21 @@ class ProgressManager @Inject constructor(
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) { private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
activeItemId = itemId activeItemId = itemId
activePlaybackReportContext = reportContext activePlaybackReportContext = reportContext
val isOffline = reportContext == null
report(itemId, positionMs, reportContext = reportContext, isStart = true) report(itemId, positionMs, reportContext = reportContext, isStart = true)
progressJob = scope.launch { progressJob = scope.launch {
while (isActive) { while (isActive) {
delay(5000) delay(5000)
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused) report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
if (isOffline) {
scope.launch(Dispatchers.IO) {
try {
mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Local cache update failed")
}
}
}
} }
} }
} }

View File

@@ -34,7 +34,6 @@ class PlayerViewModel @Inject constructor(
private val progressManager: ProgressManager, private val progressManager: ProgressManager,
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
private const val TAG = "PlayerViewModel" private const val TAG = "PlayerViewModel"
} }

View File

@@ -10,7 +10,7 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.data" namespace = "hu.bbara.purefin.data"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
minSdk = 29 minSdk = 29

View File

@@ -1,12 +1,10 @@
package hu.bbara.purefin.data.catalog package hu.bbara.purefin.data.catalog
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.concurrency.SingleFlight
import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel
import hu.bbara.purefin.data.converter.toEpisode import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toLibrary import hu.bbara.purefin.data.converter.toLibrary
import hu.bbara.purefin.data.converter.toMovie import hu.bbara.purefin.data.converter.toMovie
@@ -37,14 +35,14 @@ 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 timber.log.Timber import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID import java.util.UUID
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
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import timber.log.Timber
@Singleton @Singleton
class InMemoryAppContentRepository @Inject constructor( class InMemoryAppContentRepository @Inject constructor(
@@ -53,6 +51,7 @@ class InMemoryAppContentRepository @Inject constructor(
private val homeCacheDataStore: DataStore<HomeCache>, private val homeCacheDataStore: DataStore<HomeCache>,
private val onlineMediaRepository: InMemoryLocalMediaRepository, private val onlineMediaRepository: InMemoryLocalMediaRepository,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val singleFlight: SingleFlight,
) : HomeRepository { ) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var cacheLoadJob: Job? = null private var cacheLoadJob: Job? = null
@@ -76,17 +75,6 @@ class InMemoryAppContentRepository @Inject constructor(
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap()) private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow() override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
// dateLastMediaAdded values captured at the end of the last successful
// refresh. Used to short-circuit per-library /Items/Latest calls when
// the timestamp hasn't moved. Persisted via HomeCache.
private val cachedLibraryDateLastMediaAdded = mutableMapOf<UUID, String>()
// dateLastMediaAdded values observed during the CURRENT refresh's
// /UserViews response. Read by loadLatestLibraryContent to decide
// which libraries need a /Items/Latest call, then merged into
// cachedLibraryDateLastMediaAdded and persisted at the end.
private var currentLibraryDateLastMediaAdded: Map<UUID, String> = emptyMap()
init { init {
ensureReady() ensureReady()
} }
@@ -140,14 +128,6 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadHomeCache() { private suspend fun loadHomeCache() {
Timber.tag(TAG).d("Loading home cache") Timber.tag(TAG).d("Loading home cache")
val cache = homeCacheDataStore.data.first() val cache = homeCacheDataStore.data.first()
// Hydrate the dateLastMediaAdded map from disk before the first
// refresh runs, so the per-library short-circuit has a baseline.
cachedLibraryDateLastMediaAdded.clear()
cache.libraryDateLastMediaAdded.forEach { (key, date) ->
runCatching { UUID.fromString(key) }.getOrNull()?.let { uuid ->
cachedLibraryDateLastMediaAdded[uuid] = date
}
}
if (cache.libraries.isNotEmpty()) { if (cache.libraries.isNotEmpty()) {
val libraries = cache.libraries.mapNotNull { it.toLibrary() } val libraries = cache.libraries.mapNotNull { it.toLibrary() }
librariesState.value = libraries librariesState.value = libraries
@@ -190,12 +170,6 @@ class InMemoryAppContentRepository @Inject constructor(
val movies = onlineMediaRepository.movies.value val movies = onlineMediaRepository.movies.value
val series = onlineMediaRepository.series.value val series = onlineMediaRepository.series.value
val episodes = onlineMediaRepository.episodes.value val episodes = onlineMediaRepository.episodes.value
// Persist this refresh's dateLastMediaAdded as the new baseline
// for the next refresh's per-library short-circuit.
cachedLibraryDateLastMediaAdded.clear()
currentLibraryDateLastMediaAdded.forEach { (id, date) ->
cachedLibraryDateLastMediaAdded[id] = date
}
val cache = HomeCache( val cache = HomeCache(
suggestions = suggestionsState.value.map { it.toCachedItem() }, suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() }, continueWatching = continueWatchingState.value.map { it.toCachedItem() },
@@ -204,7 +178,6 @@ class InMemoryAppContentRepository @Inject constructor(
uuid.toString() to items.map { it.toCachedItem() } uuid.toString() to items.map { it.toCachedItem() }
}.toMap(), }.toMap(),
libraries = librariesState.value.map { it.toCachedLibrary() }, libraries = librariesState.value.map { it.toCachedLibrary() },
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() }, movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() }, series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() }, episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
@@ -239,93 +212,60 @@ class InMemoryAppContentRepository @Inject constructor(
) )
} }
private suspend fun loadLibraries() { private suspend fun loadLibraries() = singleFlight.run("AppContent:loadLibraries") {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() } val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load libraries") handleRefreshFailure(error, "Unable to load libraries")
} }
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries
// Build the new library list in a single pass, so collectors of val filledLibraries = emptyLibraries.map { loadLibrary(it) }
// `librariesState` only ever see the final value. When /UserViews
// returns 304 we already have the BaseItemDto list in memory, so
// we just rebuild from it. Otherwise we have the new DTOs.
val filledLibraries: List<Library> = if (librariesItem == null) {
// ETag 304 on /UserViews — re-fetch the count per library
// against the existing in-memory state. The size fallback
// (`?: library.size`) handles a 304 on /Items too, so a
// 304 on both endpoints leaves Library.size untouched.
librariesState.value.map { library ->
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
when (library.type) {
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
}
}
} else {
// Capture the server-reported "last media added" timestamp
// per library for this refresh. loadLatestLibraryContent
// reads this map to decide which libraries need a
// /Items/Latest call, and persistHomeCache writes it to
// HomeCache as the new "cached" baseline.
currentLibraryDateLastMediaAdded = librariesItem
.mapNotNull { dto ->
dto.dateLastMediaAdded?.let { dto.id to it.toString() }
}
.toMap()
// Count-only refresh: ask the server for the number of items
// in each library instead of enumerating them. The home
// dashboard only uses Library.size for the library cards;
// the full movies/series lists are fetched on demand by the
// library detail screen via loadLibraryContent, and on the
// home rows by loadSuggestions / loadContinueWatching /
// loadLatestLibraryContent.
librariesItem.map { dto ->
val library = dto.toLibrary(serverUrl())
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
when (library.type) {
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
}
}
}
librariesState.value = filledLibraries librariesState.value = filledLibraries
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
onlineMediaRepository.upsertMovies(movies)
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
onlineMediaRepository.upsertSeries(series)
} }
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? { private suspend fun loadLibrary(library: Library): Library {
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList() val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
// ETag hit — return null so the caller can keep its previously
// fetched copy. The library detail viewmodel uses this signal to
// preserve its in-memory cache across re-selections.
val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
val url = serverUrl()
return when (library.type) {
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
}
}
private suspend fun loadSuggestions() {
val url = serverUrl()
// Head-first fetch: ask the server for just the first 2 items and
// compare to the cached head. If the head is unchanged, the rest
// of the row is also unchanged (the home screen shows the head
// prominently and only rotates when new content arrives at the
// top), so we can skip the full request entirely.
val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load suggestions head") handleRefreshFailure(error, "Unable to load library ${library.id}")
}
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl()) },
size = contentItem.size,
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl()) },
size = contentItem.size,
)
}
} }
if (headItems == null) return
if (headMatches(headItems, suggestionsState.value)) return
// Head changed (or first refresh) — fetch the full list. private suspend fun loadSuggestions() = singleFlight.run("AppContent:loadSuggestions") {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load suggestions") handleRefreshFailure(error, "Unable to load suggestions")
} }
if (suggestionsItems == null) return // Upsert full episode details BEFORE publishing the new row, so the
// home viewmodel's `combine` of this flow with the local media
// repository never observes an empty intermediate state. If we
// assigned suggestionsState first, the new episode IDs would be
// unresolvable for a single emission and the Suggestions section
// would briefly drop from the home screen.
suggestionsItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
suggestionsState.value = suggestionsItems.mapNotNull { item -> suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
@@ -333,32 +273,24 @@ class InMemoryAppContentRepository @Inject constructor(
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}") else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
} }
} }
// Upsert full details so the home viewmodel can look up each item.
suggestionsItems.forEach { item ->
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
}
}
} }
private suspend fun loadContinueWatching() { private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") {
val url = serverUrl()
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load continue watching head")
}
if (headItems == null) return
if (headMatches(headItems, continueWatchingState.value)) return
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load continue watching") handleRefreshFailure(error, "Unable to load continue watching")
} }
if (continueWatchingItems == null) return // Upsert full episode details BEFORE publishing the new row, so the
// home viewmodel's `combine` of this flow with the local media
// repository never observes an empty intermediate state. If we
// assigned continueWatchingState first, the new episode IDs would
// be unresolvable for a single emission and the Continue Watching
// section would briefly drop from the home screen.
continueWatchingItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
}
continueWatchingState.value = continueWatchingItems.mapNotNull { item -> continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
@@ -366,102 +298,57 @@ class InMemoryAppContentRepository @Inject constructor(
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}") else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
} }
} }
continueWatchingItems.forEach { item ->
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
}
}
} }
private suspend fun loadNextUp() { private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") {
val url = serverUrl()
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load next up head")
}
if (headItems == null) return
if (headMatches(headItems, nextUpState.value)) return
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load next up") handleRefreshFailure(error, "Unable to load next up")
} }
if (nextUpItems == null) return // Upsert full episode details BEFORE publishing the new row, so the
// home viewmodel's `combine` of this flow with the local media
// repository never observes an empty intermediate state. If we
// assigned nextUpState first, the new episode IDs would be
// unresolvable for a single emission and the Next Up section
// would briefly drop from the home screen.
nextUpItems.forEach { item ->
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
nextUpState.value = nextUpItems.map { item -> nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
} }
nextUpItems.forEach { item ->
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
}
} }
private suspend fun loadLatestLibraryContent() { private suspend fun loadLatestLibraryContent() = singleFlight.run("AppContent:loadLatestLibraryContent") {
// Reuse the libraries already loaded by loadLibraries() so we don't refetch // Reuse the libraries already loaded by loadLibraries() so we don't refetch
// the same /Users/{userId}/Views response a second time per refresh. // the same /Users/{userId}/Views response a second time per refresh.
val filteredLibraries = librariesState.value val filteredLibraries = librariesState.value
val url = serverUrl() val url = serverUrl()
val latestLibraryContents = filteredLibraries.associate { library -> val latestLibraryContents = filteredLibraries.associate { library ->
// Layer 1: skip when the server-reported "last media added"
// timestamp is unchanged since the last refresh. This is the
// cheapest check (no request) and handles the common "no new
// content" case.
val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
val currentDate = currentLibraryDateLastMediaAdded[library.id]
if (cachedDate != null && cachedDate == currentDate) {
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
} else {
// Layer 2: head-only fetch. If the first 2 items of the
// latest row are the same as the cached head, the rest of
// the row is also unchanged (the home row only rotates at
// the top) and we can skip the full request.
val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
}
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
library.id to cachedSlice
} else {
// Layer 3: head changed — fetch the full row.
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
} }
if (latestFromLibrary == null) { library.id to when (library.type) {
library.id to cachedSlice
} else {
val media = when (library.type) {
LibraryKind.MOVIES -> latestFromLibrary.map { LibraryKind.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(url) val movie = it.toMovie(url)
onlineMediaRepository.upsertMovies(listOf(movie))
Media.MovieMedia(movieId = movie.id) Media.MovieMedia(movieId = movie.id)
} }
LibraryKind.SERIES -> latestFromLibrary.map { dto -> LibraryKind.SERIES -> latestFromLibrary.map {
when (dto.type) { when (it.type) {
BaseItemKind.SERIES -> { BaseItemKind.SERIES -> {
val series = dto.toSeries(url) val series = it.toSeries(url)
onlineMediaRepository.upsertSeries(listOf(series))
Media.SeriesMedia(seriesId = series.id) Media.SeriesMedia(seriesId = series.id)
} }
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
val season = dto.toSeason() val season = it.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
} }
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
val episode = dto.toEpisode(url) val episode = it.toEpisode(url)
onlineMediaRepository.upsertEpisodes(listOf(episode))
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
} }
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}") else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
}
library.id to media
} }
} }
} }
@@ -469,29 +356,6 @@ class InMemoryAppContentRepository @Inject constructor(
latestLibraryContentState.value = latestLibraryContents latestLibraryContentState.value = latestLibraryContents
} }
/**
* Returns true when the first [JellyfinApiClient.HEAD_LIMIT] IDs of the
* freshly fetched [headDtos] match the first
* [JellyfinApiClient.HEAD_LIMIT] IDs of the cached [cachedMedia]. Used
* by the home row refreshers to short-circuit the full request when
* the head of the row is unchanged.
*
* Order-sensitive: the comparison is positional. The Jellyfin server
* can reorder rows (e.g., Suggestions reorders on a relevance
* recompute even when the item set is unchanged), which will report
* a false "head changed" and pay for the full request. The reverse
* false negative — head order unchanged but a mid-list item rotated —
* is a known limitation of the head-diff heuristic.
*/
private fun headMatches(
headDtos: List<BaseItemDto>,
cachedMedia: List<Media>,
): Boolean {
val headIds = headDtos.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
val cachedIds = cachedMedia.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
return headIds == cachedIds
}
private suspend fun serverUrl(): String { private suspend fun serverUrl(): String {
return userSessionRepository.serverUrl.first() return userSessionRepository.serverUrl.first()
} }

View File

@@ -1,7 +1,9 @@
package hu.bbara.purefin.data.catalog 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.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
@@ -10,10 +12,8 @@ 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -32,10 +32,9 @@ import javax.inject.Singleton
class InMemoryLocalMediaRepository @Inject constructor( class InMemoryLocalMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val singleFlight: SingleFlight,
) : LocalMediaRepository { ) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val serverUrl = userSessionRepository.serverUrl private val serverUrl = userSessionRepository.serverUrl
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap()) private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
@@ -56,13 +55,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun getEpisode(id: UUID): Flow<Episode?> = override suspend fun getEpisode(id: UUID): Flow<Episode?> =
episodesState.map { it[id] }.distinctUntilChanged() episodesState.map { it[id] }.distinctUntilChanged()
override suspend fun loadMovie(id: UUID) { override suspend fun loadMovie(id: UUID) = singleFlight.run("LocalMedia:loadMovie:$id") {
if (moviesState.value.containsKey(id)) return
try { try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.MOVIE) { if (item.type != BaseItemKind.MOVIE) {
Timber.tag(TAG).d("Item is not an movie: ${item.type}") Timber.tag(TAG).d("Item is not an movie: ${item.type}")
return return@run
} }
val movie = item.toMovie(serverUrl.first()) val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) } moviesState.update { current -> current + (movie.id to movie) }
@@ -75,16 +73,26 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
} }
override suspend fun loadSeries(id: UUID) { override suspend fun loadSeries(id: UUID) = singleFlight.run("LocalMedia:loadSeries:$id") {
if (seriesState.value.containsKey(id)) return
try { try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) { if (item.type != BaseItemKind.SERIES) {
Timber.tag(TAG).d("Item is not an series: ${item.type}") Timber.tag(TAG).d("Item is not an series: ${item.type}")
return return@run
} }
val series = item.toSeries(serverUrl.first()) val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) } seriesState.update { current ->
val existing = current[series.id]
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) {
series.copy(
seasons = existing.seasons,
uncategorizedEpisodes = existing.uncategorizedEpisodes,
)
} else {
series
}
current + (series.id to merged)
}
} }
} catch (error: CancellationException) { } catch (error: CancellationException) {
throw error throw error
@@ -94,13 +102,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
} }
override suspend fun loadEpisode(id: UUID) { override suspend fun loadEpisode(id: UUID) = singleFlight.run("LocalMedia:loadEpisode:$id") {
if (episodesState.value.containsKey(id)) return
try { try {
jellyfinApiClient.getItemInfo(id)?.let { item -> jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) { if (item.type != BaseItemKind.EPISODE) {
Timber.tag(TAG).d("Item is not an episode: ${item.type}") Timber.tag(TAG).d("Item is not an episode: ${item.type}")
return return@run
} }
val episode = item.toEpisode(serverUrl.first()) val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) } episodesState.update { current -> current + (episode.id to episode) }
@@ -118,81 +125,116 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
fun upsertSeries(series: List<Series>) { fun upsertSeries(series: List<Series>) {
seriesState.update { current -> current + series.associateBy { it.id } } seriesState.update { current ->
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,
uncategorizedEpisodes = existing.uncategorizedEpisodes,
)
} else {
newSeries
}
}
}
} }
fun upsertEpisodes(episodes: List<Episode>) { fun upsertEpisodes(episodes: List<Episode>) {
episodesState.update { current -> current + episodes.associateBy { it.id } } episodesState.update { current -> current + episodes.associateBy { it.id } }
} }
override suspend fun loadSeasons(seriesId: UUID) { override suspend fun loadSeasons(seriesId: UUID) = singleFlight.run("LocalMedia:loadSeasons:$seriesId") {
try { try {
loadSeasonsInternal(seriesId) // Ensure the series is loaded
var series = seriesState.value[seriesId]
if (series == null) {
loadSeries(seriesId)
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() }.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) { } catch (error: CancellationException) {
throw error throw error
} catch (error: Exception) { } catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load content for series $seriesId") Timber.tag(TAG).e(error, "Failed to load seasons for series $seriesId")
throw error throw error
} }
} }
private suspend fun loadSeasonsInternal(seriesId: UUID) { override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } 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
val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } // Ensure the series and season is loaded
var series = seriesState.value[seriesId]
// Await the parallel loadSeries to populate the series entry. if (series == null) {
// Precondition: seriesId refers to a real series (selectSeries always uses a loadSeries(seriesId)
// SeriesDto from a real series list). If loadSeries returns without populating series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
// state (null item / wrong type) this suspends until cancelled by structured
// concurrency when a sibling load* call fails.
seriesState.first { it.containsKey(seriesId) }
seriesState.update { current ->
val existing = current[seriesId] ?: return@update current
if (existing.seasons.isNotEmpty()) return@update current
current + (seriesId to existing.copy(seasons = seasons))
} }
// The season we are about to load episodes for must be present
// in `series.seasons` for the `seriesState.update { ... }` block
// below to attach the fetched episodes to it; otherwise the
// `map { ... }` iterates over an empty list and the freshly
// fetched episodes are silently dropped, leaving the series
// detail screen stuck on "Loading seasons…". If the seasons
// have not been hydrated yet (e.g. `loadSeasons` from
// `selectSeries` is still racing with us), load them first.
if (series.seasons.none { it.id == seasonId }) {
loadSeasons(seriesId)
} }
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
// Fast path: season already cached with episodes or known empty — skip the fetch.
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
}
// Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
// seasonId is always a valid season from a real EpisodeDto in this path.
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)
// Await the season to be present in state (immediate when pre-loaded; waits for .partition { !it.isUncategorizedEpisode() }
// the parallel loadSeasons otherwise). val categorizedEpisodes = categorized.map {
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true } it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
}
// Re-check guard after await: a concurrent call may have filled episodes already. val uncategorizedEpisodes = uncategorized.map {
val series = seriesState.value[seriesId] ?: return it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return
} }
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) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load episodes for season $seasonId")
throw error
}
} }
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
@@ -269,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
} }

View File

@@ -9,14 +9,18 @@ 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 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? {
return when (collectionType) { return when (collectionType) {
CollectionType.MOVIES -> Library( CollectionType.MOVIES -> Library(
id = id, id = id,
@@ -42,10 +46,20 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
size = childCount ?: 0, size = childCount ?: 0,
series = emptyList(), series = emptyList(),
) )
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType") else -> {
// Jellyfin servers can return other top-level views (e.g. boxsets,
// music, photos, live TV) that this app does not surface on the
// home dashboard. Skip them so a single unsupported view does
// not abort the whole refresh and leave the user with an empty
// home screen.
Timber.tag(TAG).w("Skipping unsupported library type: $collectionType")
null
}
} }
} }
private const val TAG = "BaseItemDtoConverter"
fun BaseItemDto.toMovie(serverUrl: String): Movie { fun BaseItemDto.toMovie(serverUrl: String): Movie {
return Movie( return Movie(
id = id, id = id,
@@ -90,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,

View File

@@ -20,6 +20,7 @@ import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
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.PlaybackSource import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
import hu.bbara.purefin.data.jellyfin.playback.SubtitleConfigurationMapper
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
@@ -50,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val mediaDownloadController: MediaDownloadController, private val mediaDownloadController: MediaDownloadController,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val subtitleConfigurationMapper: SubtitleConfigurationMapper,
@param:Offline private val offlineMediaRepository: LocalMediaRepository, @param:Offline private val offlineMediaRepository: LocalMediaRepository,
private val offlineMediaManager: OfflineMediaManager, private val offlineMediaManager: OfflineMediaManager,
) : PlayableMediaRepository { ) : PlayableMediaRepository {
@@ -198,6 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
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)
val subtitleConfigurations = subtitleConfigurationMapper.createSubtitleConfigurations(
mediaSource = playbackSource.mediaSource,
serverUrl = serverUrl,
)
val mediaItem = createMediaItem( val mediaItem = createMediaItem(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
url = playbackSource.directPlayUrl, url = playbackSource.directPlayUrl,
@@ -205,6 +212,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
subtitle = seasonEpisodeLabel(baseItem), subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
playbackTag = playbackSource.toPlaybackMediaItemTag(), playbackTag = playbackSource.toPlaybackMediaItemTag(),
subtitleConfigurations = subtitleConfigurations,
) )
return@withContext mediaItem return@withContext mediaItem
@@ -283,6 +291,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
subtitle: String?, subtitle: String?,
artworkUrl: String, artworkUrl: String,
playbackTag: Any?, playbackTag: Any?,
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
): MediaItem { ): MediaItem {
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
@@ -294,6 +303,9 @@ class DefaultPlayableMediaRepository @Inject constructor(
.setMediaId(mediaId) .setMediaId(mediaId)
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.setTag(playbackTag) .setTag(playbackTag)
if (subtitleConfigurations.isNotEmpty()) {
builder.setSubtitleConfigurations(subtitleConfigurations)
}
return builder.build() return builder.build()
} }

View File

@@ -8,8 +8,6 @@ import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.QuickConnectSession import hu.bbara.purefin.core.data.QuickConnectSession
import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
import hu.bbara.purefin.data.jellyfin.etag.NotModifiedException
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -34,7 +32,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.discovery.RecommendedServerInfo import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
@@ -69,12 +66,10 @@ import javax.inject.Singleton
class JellyfinApiClient @Inject constructor( class JellyfinApiClient @Inject constructor(
@ApplicationContext private val applicationContext: Context, @ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
) { ) {
private val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = applicationContext context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
apiClientFactory = okHttpFactory
} }
val api = jellyfin.createApi() val api = jellyfin.createApi()
@@ -219,21 +214,17 @@ class JellyfinApiClient @Inject constructor(
} }
} }
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) { suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLibraries") { logRequest("getLibraries") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() return@logRequest emptyList()
} }
try {
val response = api.userViewsApi.getUserViews( val response = api.userViewsApi.getUserViews(
userId = getUserId(), userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS), presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false, includeHidden = false,
) )
response.content.items response.content.items
} catch (e: NotModifiedException) {
null
}
} }
} }
@@ -247,12 +238,11 @@ class JellyfinApiClient @Inject constructor(
} }
} }
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) { suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLibraryContent") { logRequest("getLibraryContent") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() return@logRequest emptyList()
} }
try {
val getItemsRequest = GetItemsRequest( val getItemsRequest = GetItemsRequest(
userId = getUserId(), userId = getUserId(),
enableImages = true, enableImages = true,
@@ -264,52 +254,15 @@ class JellyfinApiClient @Inject constructor(
) )
val response = api.itemsApi.getItems(getItemsRequest) val response = api.itemsApi.getItems(getItemsRequest)
response.content.items response.content.items
} catch (e: NotModifiedException) {
null
}
} }
} }
/** suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
* Returns the total number of items in [libraryId] without enumerating
* them. Uses `getItems` with `limit=0, enableTotalRecordCount=true`,
* which the Jellyfin server short-circuits to a single integer in the
* response. The home page uses this to populate `Library.size` for the
* library cards on the dashboard without paying for the full library
* content.
*
* ETag is honored via the [ETagInterceptor]: `null` means the cached
* count is still current.
*/
suspend fun getLibraryItemCount(libraryId: UUID): Int? = withContext(Dispatchers.IO) {
logRequest("getLibraryItemCount") {
if (!ensureConfigured()) {
return@logRequest 0
}
try {
val request = GetItemsRequest(
userId = getUserId(),
parentId = libraryId,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
limit = 0,
enableTotalRecordCount = true,
)
val response = api.itemsApi.getItems(request)
response.content.totalRecordCount
} catch (e: NotModifiedException) {
null
}
}
}
suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getSuggestions") { logRequest("getSuggestions") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() return@logRequest emptyList()
} }
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>() val userId = getUserId() ?: return@logRequest emptyList()
try {
val response = api.suggestionsApi.getSuggestions( val response = api.suggestionsApi.getSuggestions(
userId = userId, userId = userId,
mediaType = listOf(MediaType.VIDEO), mediaType = listOf(MediaType.VIDEO),
@@ -318,19 +271,15 @@ class JellyfinApiClient @Inject constructor(
enableTotalRecordCount = true, enableTotalRecordCount = true,
) )
response.content.items response.content.items
} catch (e: NotModifiedException) {
null
}
} }
} }
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) { suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getContinueWatching") { logRequest("getContinueWatching") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() return@logRequest emptyList()
} }
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>() val userId = getUserId() ?: return@logRequest emptyList()
try {
val getResumeItemsRequest = GetResumeItemsRequest( val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId, userId = userId,
fields = defaultItemFields + ItemFields.OVERVIEW, fields = defaultItemFields + ItemFields.OVERVIEW,
@@ -340,18 +289,14 @@ class JellyfinApiClient @Inject constructor(
) )
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest) val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items response.content.items
} catch (e: NotModifiedException) {
null
}
} }
} }
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) { suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getNextUpEpisodes") { logRequest("getNextUpEpisodes") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() throw IllegalStateException("Not configured")
} }
try {
val getNextUpRequest = GetNextUpRequest( val getNextUpRequest = GetNextUpRequest(
userId = getUserId(), userId = getUserId(),
fields = defaultItemFields + ItemFields.OVERVIEW, fields = defaultItemFields + ItemFields.OVERVIEW,
@@ -359,18 +304,14 @@ class JellyfinApiClient @Inject constructor(
) )
val result = api.tvShowsApi.getNextUp(getNextUpRequest) val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items result.content.items
} catch (e: NotModifiedException) {
null
}
} }
} }
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) { suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLatestFromLibrary") { logRequest("getLatestFromLibrary") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>() return@logRequest emptyList()
} }
try {
val response = api.userLibraryApi.getLatestMedia( val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(), userId = getUserId(),
parentId = libraryId, parentId = libraryId,
@@ -379,108 +320,6 @@ class JellyfinApiClient @Inject constructor(
limit = 15, limit = 15,
) )
response.content response.content
} catch (e: NotModifiedException) {
null
}
}
}
/**
* Head-only fetch for the suggestions row. Returns the first [limit]
* items so the caller can diff against the cached head and decide
* whether the full request is necessary. ETag is honored via the
* [ETagInterceptor]; `null` means the cached head is still current.
*/
suspend fun getSuggestionsHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getSuggestionsHead") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
try {
val response = api.suggestionsApi.getSuggestions(
userId = getUserId(),
mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = limit,
enableTotalRecordCount = false,
)
response.content.items
} catch (e: NotModifiedException) {
null
}
}
}
/**
* Head-only fetch for the continue-watching row. See [getSuggestionsHead].
*/
suspend fun getContinueWatchingHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getContinueWatchingHead") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
try {
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = defaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
limit = limit,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items
} catch (e: NotModifiedException) {
null
}
}
}
/**
* Head-only fetch for the next-up row. See [getSuggestionsHead].
*/
suspend fun getNextUpHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getNextUpHead") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
try {
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = defaultItemFields,
enableResumable = false,
limit = limit,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items
} catch (e: NotModifiedException) {
null
}
}
}
/**
* Head-only fetch for the latest row of a library. See [getSuggestionsHead].
*/
suspend fun getLatestFromLibraryHead(libraryId: UUID, limit: Int = HEAD_LIMIT): List<BaseItemDto>? =
withContext(Dispatchers.IO) {
logRequest("getLatestFromLibraryHead") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
try {
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = defaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = limit,
)
response.content
} catch (e: NotModifiedException) {
null
}
} }
} }
@@ -796,9 +635,5 @@ class JellyfinApiClient @Inject constructor(
companion object { companion object {
private const val TAG = "JellyfinApiClient" private const val TAG = "JellyfinApiClient"
// How many items to fetch on the head-only path. Two is enough to
// detect the common case of "first card unchanged" without making
// the head request meaningfully heavier than the minimum.
const val HEAD_LIMIT = 2
} }
} }

View File

@@ -1,84 +0,0 @@
package hu.bbara.purefin.data.jellyfin.etag
import okhttp3.HttpUrl
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* In-memory store of `ETag` response headers, keyed by full request URL. The
* [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing
* requests and writes to it on every successful response.
*
* ETag handling is scoped to a small set of home-refresh URL patterns
* matched against path segments. All other endpoints (per-item lookups,
* search, sessions, system info, etc.) are passed through unchanged. This
* keeps the 304 → [NotModifiedException] translation from leaking into code
* paths that do not expect it, and prevents search and similar list-shaped
* calls from being treated as cacheable.
*
* The cache is process-scoped: ETags are lost on process death, which means
* the first request after each app start always goes out without
* `If-None-Match`. This is acceptable — the server returns the same data
* with a fresh ETag, and subsequent requests benefit from the cache.
*/
@Singleton
class ETagCache @Inject constructor() {
private val etags = ConcurrentHashMap<String, String>()
/**
* Returns `true` if requests to [url] should use ETag-based conditional
* fetching. Only home-refresh endpoints are eligible, and the
* `/Items` list endpoint is only eligible when it is being used as a
* per-library content query (a `parentId` query parameter is present
* and no search-shaped parameters are set).
*/
fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url)
/**
* Returns the cached ETag for [url], or `null` if none has been stored
* yet.
*/
fun get(url: String): String? = etags[url]
/**
* Cache [etag] for [url]. The interceptor writes here after every
* eligible 2xx response that carried an ETag header.
*/
fun put(url: String, etag: String) {
etags[url] = etag
}
private fun matchesHomeRefresh(url: HttpUrl): Boolean {
val segments = url.pathSegments
return when {
// /Users/{userId}/Views — get libraries
segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true
// /Items/Suggestions — get suggestions
segments == listOf("Items", "Suggestions") -> true
// /Users/{userId}/Items/Resume — get resume items (continue watching)
segments.size == 4 &&
segments[0] == "Users" &&
segments[2] == "Items" &&
segments[3] == "Resume" -> true
// /Shows/NextUp — get next up episodes
segments == listOf("Shows", "NextUp") -> true
// /Users/{userId}/Items/Latest — latest items in a library
segments.size == 4 &&
segments[0] == "Users" &&
segments[2] == "Items" &&
segments[3] == "Latest" -> true
// /Items — per-library content list, distinguished from search
// by the presence of `parentId` and the absence of search params.
segments == listOf("Items") -> isPerLibraryItemsQuery(url)
else -> false
}
}
private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean {
// The per-library content call always sets parentId; the search
// calls set searchTerm or genres but never parentId. Matching
// on parentId alone is enough to keep the ETag scope tight.
return url.queryParameter("parentId") != null
}
}

View File

@@ -1,71 +0,0 @@
package hu.bbara.purefin.data.jellyfin.etag
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* OkHttp interceptor that turns Jellyfin's ETag support into a
* "not modified" signal the SDK caller can act on.
*
* For URLs whose path is in [ETagCache.isEligible]:
* - If a cached ETag exists, the request is sent with `If-None-Match`.
* - If the server replies 304, the response is closed and a
* [NotModifiedException] is thrown so the caller can keep using its
* existing copy of the data.
* - If the server replies 2xx with an ETag header, the new ETag is cached.
*
* All other URLs are passed through untouched. This bounds the surface
* area of the 304 handling: only code paths that explicitly know about
* the home-refresh contract need to catch [NotModifiedException].
*/
@Singleton
class ETagInterceptor @Inject constructor(
private val cache: ETagCache,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!cache.isEligible(request.url)) {
return chain.proceed(request)
}
val url = request.url.toString()
val etag = cache.get(url)
val outgoing = if (etag != null) {
request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build()
} else {
request
}
val response = chain.proceed(outgoing)
if (response.code == HTTP_NOT_MODIFIED) {
// Close before throwing so OkHttp does not warn about leaked
// responses. The caller will treat this as "use the cached copy".
response.close()
Timber.tag(TAG).d("ETag hit for %s", url)
throw NotModifiedException()
}
// Cache the ETag for next time. Only successful responses carry
// meaningful ETags; error responses may carry caching hints but
// we should not treat a 5xx body as a valid cache key.
if (response.isSuccessful) {
response.header(HEADER_ETAG)?.let { newEtag ->
cache.put(url, newEtag)
}
}
return response
}
private companion object {
const val TAG = "ETagInterceptor"
const val HEADER_ETAG = "ETag"
const val HEADER_IF_NONE_MATCH = "If-None-Match"
const val HTTP_NOT_MODIFIED = 304
}
}

View File

@@ -1,37 +0,0 @@
package hu.bbara.purefin.data.jellyfin.etag
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import javax.inject.Singleton
/**
* Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the
* [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with
* [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient
* in `core.jellyfin.JellyfinNetworkModule`, which is used for image and
* media streaming. The Jellyfin SDK provides its own auth header on every
* request, so the SDK-bound client does not need `JellyfinAuthInterceptor`.
*/
@Module
@InstallIn(SingletonComponent::class)
object JellyfinOkHttpModule {
@Provides
@Singleton
@JellyfinSdkClient
fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(etagInterceptor)
.build()
@Provides
@Singleton
@JellyfinSdkClient
fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory =
OkHttpFactory(base = client)
}

View File

@@ -1,12 +0,0 @@
package hu.bbara.purefin.data.jellyfin.etag
/**
* Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified
* for a request that had an `If-None-Match` header. Callers should treat this
* as "the cached copy is still valid" and avoid re-processing the response.
*
* This is a control-flow signal, not an error — it propagates out of
* `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only
* URLs that have been registered in [ETagCache] can produce this exception.
*/
class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified")

View File

@@ -0,0 +1,77 @@
package hu.bbara.purefin.data.jellyfin.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import javax.inject.Inject
class SubtitleConfigurationMapper @Inject constructor() {
@OptIn(UnstableApi::class)
fun createSubtitleConfigurations(
mediaSource: MediaSourceInfo,
serverUrl: String,
): List<MediaItem.SubtitleConfiguration> {
val streams = mediaSource.mediaStreams.orEmpty()
if (streams.isEmpty()) return emptyList()
return streams.mapNotNull { stream ->
if (stream.type != MediaStreamType.SUBTITLE) return@mapNotNull null
if (stream.deliveryMethod != SubtitleDeliveryMethod.EXTERNAL) return@mapNotNull null
val deliveryUrl = stream.deliveryUrl?.trim()?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val mimeType = mimeTypeForCodec(stream.codec)
?: return@mapNotNull null
val resolvedUri = resolveUrl(serverUrl, deliveryUrl)
val selectionFlags = buildSelectionFlags(stream.isForced, stream.isDefault)
MediaItem.SubtitleConfiguration.Builder(resolvedUri.toUri())
.setMimeType(mimeType)
.setLanguage(stream.language)
.setLabel(stream.displayTitle)
.setSelectionFlags(selectionFlags)
.build()
}
}
@OptIn(UnstableApi::class)
private fun mimeTypeForCodec(codec: String?): String? {
if (codec.isNullOrBlank()) return null
return when (codec.lowercase().trim()) {
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"ttml" -> MimeTypes.APPLICATION_TTML
"ass", "ssa" -> MimeTypes.TEXT_SSA
else -> null
}
}
@OptIn(UnstableApi::class)
private fun buildSelectionFlags(isForced: Boolean?, isDefault: Boolean?): Int {
var flags = 0
if (isForced == true) flags = flags or C.SELECTION_FLAG_FORCED
if (isDefault == true) flags = flags or C.SELECTION_FLAG_DEFAULT
return flags
}
private fun resolveUrl(serverUrl: String, url: String): String {
if (
url.startsWith("http://", ignoreCase = true) ||
url.startsWith("https://", ignoreCase = true)
) {
return url
}
return "${serverUrl.trimEnd('/')}/${url.trimStart('/')}"
}
private fun String.toUri() = android.net.Uri.parse(this)
}

View File

@@ -91,7 +91,6 @@ data class HomeCache(
val nextUp: List<CachedMediaItem> = emptyList(), val nextUp: List<CachedMediaItem> = emptyList(),
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(), val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
val libraries: List<CachedLibrary> = emptyList(), val libraries: List<CachedLibrary> = emptyList(),
val libraryDateLastMediaAdded: Map<String, String> = emptyMap(),
val movies: List<CachedMovie> = emptyList(), val movies: List<CachedMovie> = emptyList(),
val series: List<CachedSeries> = emptyList(), val series: List<CachedSeries> = emptyList(),
val episodes: List<CachedEpisode> = emptyList() val episodes: List<CachedEpisode> = emptyList()

View File

@@ -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,

View File

@@ -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=1000030 purefinReleaseVersionCode=1000037
purefinDebugVersionCode=1000003 purefinDebugVersionCode=1000003
purefinTvVersionCode=2000014 purefinTvVersionCode=2000023

View File

@@ -1,35 +1,34 @@
[versions] [versions]
agp = "9.2.1" agp = "9.3.0"
coreKtx = "1.15.0" coreKtx = "1.19.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.3.0"
espressoCore = "3.6.1" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.9.3" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.4.10"
composeBom = "2025.02.00" composeBom = "2026.06.01"
jellyfin-core = "1.8.8" jellyfin-core = "1.8.11"
hilt = "2.57.2" hilt = "2.60.1"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.4.0"
ksp = "2.3.2" ksp = "2.3.10"
datastore = "1.1.1" datastore = "1.2.1"
kotlinxSerializationJson = "1.7.3" kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.9.0" kotlinxCoroutines = "1.11.0"
okhttp = "4.12.0" okhttp = "5.4.0"
foundation = "1.10.1" foundation = "1.11.4"
tvMaterial = "1.0.1" tvMaterial = "1.1.0"
coil = "3.3.0" coil = "3.5.0"
media3 = "1.9.0" media3 = "1.10.1"
media3FfmpegDecoder = "1.9.0+1" media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0" nav3Core = "1.1.4"
room = "2.7.0" room = "2.8.4"
slf4j = "2.0.17" slf4j = "2.0.18"
timber = "5.0.1" timber = "5.0.1"
firebaseCrashlytics = "20.0.5" firebaseCrashlytics = "20.1.0"
googleGmsGoogleServices = "4.4.4" googleGmsGoogleServices = "4.5.0"
googleFirebaseCrashlytics = "3.0.7" googleFirebaseCrashlytics = "3.0.7"
foundationVersion = "1.11.2" animation = "1.11.4"
animation = "1.11.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -66,6 +65,8 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"} medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3"} medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3"}
media3-exoplayer-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3"}
media3-exoplayer-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3"}
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"} medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"} medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"} medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
@@ -78,10 +79,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" } androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }

View File

@@ -1,8 +1,8 @@
#Thu Jan 15 19:48:32 CET 2026 #Thu Jan 15 19:48:32 CET 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

@@ -0,0 +1 @@
{"0": "Playback Profile Family", "1": "Media Segment Model", "2": "Download Controller", "3": "Download Media Source", "4": "Jellyfin Playback Profiles", "5": "Playable Media Model", "6": "Season & Series Model", "7": "Login Screen", "8": "Playback Progress Reporter", "9": "TV Home Content Tests", "10": "Offline Media Repository", "11": "TV Hero Tests", "12": "Track Mapper", "13": "Jellyfin API Client", "14": "Composite Media Repository", "15": "Logging & Logger", "16": "In-Memory App Content", "17": "TV Nav Drawer Tests", "18": "Authentication Repository", "19": "Home Refresh Side Effect", "20": "Player UI Models", "21": "Episode & Series Feature", "22": "Section Header UI", "23": "Home Screen UI", "24": "Player Screen UI", "25": "Settings Screen UI", "26": "Room Database Layer", "27": "Playback Profile Policy", "28": "Media Detail Scaffold", "29": "Download Service", "30": "Media Detail Components", "31": "TV Navigation Module", "32": "Media Metadata Updater", "33": "Poster Card UI", "34": "Series Screen UI", "35": "Settings ViewModel", "36": "Library Screen UI", "37": "Movie Screen UI", "38": "Media Detail Shell", "39": "Community 39", "40": "Community 40", "41": "Community 41", "42": "Community 42", "43": "Community 43", "44": "Community 44", "45": "Community 45", "46": "Community 46", "47": "Community 47", "48": "Community 48", "49": "Community 49", "50": "Community 50", "51": "Community 51", "52": "Community 52", "53": "Community 53", "54": "Community 54", "55": "Community 55", "56": "Community 56", "57": "Community 57", "58": "Community 58", "59": "Community 59", "60": "Community 60", "61": "Community 61", "62": "Community 62", "63": "Community 63", "64": "Community 64", "65": "Community 65", "66": "Community 66", "67": "Community 67", "68": "Community 68", "69": "Community 69", "70": "Community 70", "71": "Community 71", "72": "Community 72", "73": "Community 73", "74": "Community 74", "75": "Community 75", "76": "Community 76", "77": "Community 77", "78": "Community 78", "79": "Community 79", "80": "Community 80", "81": "Community 81", "82": "Community 82", "83": "Community 83", "84": "Community 84", "85": "Community 85", "86": "Community 86", "87": "Community 87", "88": "Community 88", "89": "Community 89", "90": "Community 90", "91": "Community 91", "92": "Community 92", "93": "Community 93", "94": "Community 94", "95": "Community 95", "96": "Community 96", "97": "Community 97", "98": "Community 98", "99": "Community 99", "100": "Community 100", "101": "Community 101", "102": "Community 102", "103": "Community 103", "104": "Community 104", "105": "Community 105", "106": "Community 106", "107": "Community 107", "108": "Community 108", "109": "Community 109", "110": "Community 110", "111": "Community 111", "112": "Community 112", "113": "Community 113", "114": "Community 114", "115": "Community 115", "116": "Community 116", "117": "Community 117", "118": "Community 118", "119": "Community 119", "120": "Community 120", "121": "Community 121", "122": "Community 122", "123": "Community 123", "124": "Community 124", "125": "Community 125", "126": "Community 126", "127": "Community 127", "128": "Community 128", "129": "Community 129", "130": "Community 130", "131": "Community 131", "132": "Community 132", "133": "Community 133", "134": "Community 134", "135": "Community 135", "136": "Community 136", "137": "Community 137", "138": "Community 138", "139": "Community 139", "140": "Community 140", "141": "Community 141", "142": "Community 142", "143": "Community 143", "144": "Community 144", "145": "Community 145", "146": "Community 146", "147": "Community 147", "148": "Community 148", "149": "Community 149", "150": "Community 150", "151": "Community 151", "152": "Community 152", "153": "Community 153", "154": "Community 154", "155": "Community 155", "156": "Community 156", "157": "Community 157", "158": "Community 158", "159": "Community 159", "160": "Community 160", "161": "Community 161", "162": "Community 162", "163": "Community 163", "164": "Community 164", "165": "Community 165", "166": "Community 166", "167": "Community 167", "168": "Community 168", "169": "Community 169", "170": "Community 170", "171": "Community 171", "172": "Community 172", "173": "Community 173", "174": "Community 174", "175": "Community 175", "176": "Community 176", "177": "Community 177", "178": "Community 178", "179": "Community 179", "180": "Community 180", "181": "Community 181", "182": "Community 182", "183": "Community 183", "184": "Community 184", "185": "Community 185", "186": "Community 186", "187": "Community 187", "188": "Community 188", "189": "Community 189"}

View File

@@ -0,0 +1 @@
/home/.local/share/pipx/venvs/graphifyy/bin/python

View File

@@ -0,0 +1 @@
/home/repositories/Purefin

View File

@@ -0,0 +1,883 @@
# Graph Report - . (2026-07-04)
## Corpus Check
- 349 files · ~84,907 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 2741 nodes · 4999 edges · 190 communities (172 shown, 18 thin omitted)
- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 552 edges (avg confidence: 0.81)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- [[_COMMUNITY_Playback Profile Family|Playback Profile Family]]
- [[_COMMUNITY_Media Segment Model|Media Segment Model]]
- [[_COMMUNITY_Download Controller|Download Controller]]
- [[_COMMUNITY_Download Media Source|Download Media Source]]
- [[_COMMUNITY_Jellyfin Playback Profiles|Jellyfin Playback Profiles]]
- [[_COMMUNITY_Playable Media Model|Playable Media Model]]
- [[_COMMUNITY_Season & Series Model|Season & Series Model]]
- [[_COMMUNITY_Login Screen|Login Screen]]
- [[_COMMUNITY_Playback Progress Reporter|Playback Progress Reporter]]
- [[_COMMUNITY_TV Home Content Tests|TV Home Content Tests]]
- [[_COMMUNITY_Offline Media Repository|Offline Media Repository]]
- [[_COMMUNITY_TV Hero Tests|TV Hero Tests]]
- [[_COMMUNITY_Track Mapper|Track Mapper]]
- [[_COMMUNITY_Jellyfin API Client|Jellyfin API Client]]
- [[_COMMUNITY_Composite Media Repository|Composite Media Repository]]
- [[_COMMUNITY_Logging & Logger|Logging & Logger]]
- [[_COMMUNITY_In-Memory App Content|In-Memory App Content]]
- [[_COMMUNITY_TV Nav Drawer Tests|TV Nav Drawer Tests]]
- [[_COMMUNITY_Authentication Repository|Authentication Repository]]
- [[_COMMUNITY_Home Refresh Side Effect|Home Refresh Side Effect]]
- [[_COMMUNITY_Player UI Models|Player UI Models]]
- [[_COMMUNITY_Episode & Series Feature|Episode & Series Feature]]
- [[_COMMUNITY_Section Header UI|Section Header UI]]
- [[_COMMUNITY_Home Screen UI|Home Screen UI]]
- [[_COMMUNITY_Player Screen UI|Player Screen UI]]
- [[_COMMUNITY_Settings Screen UI|Settings Screen UI]]
- [[_COMMUNITY_Room Database Layer|Room Database Layer]]
- [[_COMMUNITY_Playback Profile Policy|Playback Profile Policy]]
- [[_COMMUNITY_Media Detail Scaffold|Media Detail Scaffold]]
- [[_COMMUNITY_Download Service|Download Service]]
- [[_COMMUNITY_Media Detail Components|Media Detail Components]]
- [[_COMMUNITY_TV Navigation Module|TV Navigation Module]]
- [[_COMMUNITY_Media Metadata Updater|Media Metadata Updater]]
- [[_COMMUNITY_Poster Card UI|Poster Card UI]]
- [[_COMMUNITY_Series Screen UI|Series Screen UI]]
- [[_COMMUNITY_Settings ViewModel|Settings ViewModel]]
- [[_COMMUNITY_Library Screen UI|Library Screen UI]]
- [[_COMMUNITY_Movie Screen UI|Movie Screen UI]]
- [[_COMMUNITY_Media Detail Shell|Media Detail Shell]]
- [[_COMMUNITY_Community 39|Community 39]]
- [[_COMMUNITY_Community 40|Community 40]]
- [[_COMMUNITY_Community 41|Community 41]]
- [[_COMMUNITY_Community 42|Community 42]]
- [[_COMMUNITY_Community 43|Community 43]]
- [[_COMMUNITY_Community 44|Community 44]]
- [[_COMMUNITY_Community 45|Community 45]]
- [[_COMMUNITY_Community 46|Community 46]]
- [[_COMMUNITY_Community 47|Community 47]]
- [[_COMMUNITY_Community 48|Community 48]]
- [[_COMMUNITY_Community 49|Community 49]]
- [[_COMMUNITY_Community 50|Community 50]]
- [[_COMMUNITY_Community 51|Community 51]]
- [[_COMMUNITY_Community 52|Community 52]]
- [[_COMMUNITY_Community 53|Community 53]]
- [[_COMMUNITY_Community 54|Community 54]]
- [[_COMMUNITY_Community 55|Community 55]]
- [[_COMMUNITY_Community 56|Community 56]]
- [[_COMMUNITY_Community 57|Community 57]]
- [[_COMMUNITY_Community 58|Community 58]]
- [[_COMMUNITY_Community 59|Community 59]]
- [[_COMMUNITY_Community 60|Community 60]]
- [[_COMMUNITY_Community 61|Community 61]]
- [[_COMMUNITY_Community 62|Community 62]]
- [[_COMMUNITY_Community 63|Community 63]]
- [[_COMMUNITY_Community 64|Community 64]]
- [[_COMMUNITY_Community 65|Community 65]]
- [[_COMMUNITY_Community 66|Community 66]]
- [[_COMMUNITY_Community 67|Community 67]]
- [[_COMMUNITY_Community 68|Community 68]]
- [[_COMMUNITY_Community 69|Community 69]]
- [[_COMMUNITY_Community 70|Community 70]]
- [[_COMMUNITY_Community 71|Community 71]]
- [[_COMMUNITY_Community 72|Community 72]]
- [[_COMMUNITY_Community 73|Community 73]]
- [[_COMMUNITY_Community 74|Community 74]]
- [[_COMMUNITY_Community 75|Community 75]]
- [[_COMMUNITY_Community 76|Community 76]]
- [[_COMMUNITY_Community 77|Community 77]]
- [[_COMMUNITY_Community 78|Community 78]]
- [[_COMMUNITY_Community 79|Community 79]]
- [[_COMMUNITY_Community 80|Community 80]]
- [[_COMMUNITY_Community 81|Community 81]]
- [[_COMMUNITY_Community 82|Community 82]]
- [[_COMMUNITY_Community 83|Community 83]]
- [[_COMMUNITY_Community 84|Community 84]]
- [[_COMMUNITY_Community 85|Community 85]]
- [[_COMMUNITY_Community 86|Community 86]]
- [[_COMMUNITY_Community 87|Community 87]]
- [[_COMMUNITY_Community 88|Community 88]]
- [[_COMMUNITY_Community 89|Community 89]]
- [[_COMMUNITY_Community 90|Community 90]]
- [[_COMMUNITY_Community 91|Community 91]]
- [[_COMMUNITY_Community 92|Community 92]]
- [[_COMMUNITY_Community 93|Community 93]]
- [[_COMMUNITY_Community 94|Community 94]]
- [[_COMMUNITY_Community 95|Community 95]]
- [[_COMMUNITY_Community 96|Community 96]]
- [[_COMMUNITY_Community 97|Community 97]]
- [[_COMMUNITY_Community 98|Community 98]]
- [[_COMMUNITY_Community 99|Community 99]]
- [[_COMMUNITY_Community 100|Community 100]]
- [[_COMMUNITY_Community 101|Community 101]]
- [[_COMMUNITY_Community 102|Community 102]]
- [[_COMMUNITY_Community 103|Community 103]]
- [[_COMMUNITY_Community 104|Community 104]]
- [[_COMMUNITY_Community 105|Community 105]]
- [[_COMMUNITY_Community 106|Community 106]]
- [[_COMMUNITY_Community 107|Community 107]]
- [[_COMMUNITY_Community 108|Community 108]]
- [[_COMMUNITY_Community 109|Community 109]]
- [[_COMMUNITY_Community 110|Community 110]]
- [[_COMMUNITY_Community 111|Community 111]]
- [[_COMMUNITY_Community 112|Community 112]]
- [[_COMMUNITY_Community 113|Community 113]]
- [[_COMMUNITY_Community 114|Community 114]]
- [[_COMMUNITY_Community 115|Community 115]]
- [[_COMMUNITY_Community 116|Community 116]]
- [[_COMMUNITY_Community 117|Community 117]]
- [[_COMMUNITY_Community 118|Community 118]]
- [[_COMMUNITY_Community 119|Community 119]]
- [[_COMMUNITY_Community 120|Community 120]]
- [[_COMMUNITY_Community 121|Community 121]]
- [[_COMMUNITY_Community 122|Community 122]]
- [[_COMMUNITY_Community 123|Community 123]]
- [[_COMMUNITY_Community 124|Community 124]]
- [[_COMMUNITY_Community 125|Community 125]]
- [[_COMMUNITY_Community 126|Community 126]]
- [[_COMMUNITY_Community 127|Community 127]]
- [[_COMMUNITY_Community 128|Community 128]]
- [[_COMMUNITY_Community 129|Community 129]]
- [[_COMMUNITY_Community 130|Community 130]]
- [[_COMMUNITY_Community 131|Community 131]]
- [[_COMMUNITY_Community 132|Community 132]]
- [[_COMMUNITY_Community 133|Community 133]]
- [[_COMMUNITY_Community 134|Community 134]]
- [[_COMMUNITY_Community 135|Community 135]]
- [[_COMMUNITY_Community 136|Community 136]]
- [[_COMMUNITY_Community 137|Community 137]]
- [[_COMMUNITY_Community 138|Community 138]]
- [[_COMMUNITY_Community 139|Community 139]]
- [[_COMMUNITY_Community 140|Community 140]]
- [[_COMMUNITY_Community 141|Community 141]]
- [[_COMMUNITY_Community 142|Community 142]]
- [[_COMMUNITY_Community 143|Community 143]]
- [[_COMMUNITY_Community 144|Community 144]]
- [[_COMMUNITY_Community 145|Community 145]]
- [[_COMMUNITY_Community 146|Community 146]]
- [[_COMMUNITY_Community 147|Community 147]]
- [[_COMMUNITY_Community 148|Community 148]]
- [[_COMMUNITY_Community 149|Community 149]]
- [[_COMMUNITY_Community 150|Community 150]]
- [[_COMMUNITY_Community 151|Community 151]]
- [[_COMMUNITY_Community 152|Community 152]]
- [[_COMMUNITY_Community 153|Community 153]]
- [[_COMMUNITY_Community 154|Community 154]]
- [[_COMMUNITY_Community 155|Community 155]]
- [[_COMMUNITY_Community 156|Community 156]]
- [[_COMMUNITY_Community 157|Community 157]]
- [[_COMMUNITY_Community 158|Community 158]]
- [[_COMMUNITY_Community 159|Community 159]]
- [[_COMMUNITY_Community 160|Community 160]]
- [[_COMMUNITY_Community 161|Community 161]]
- [[_COMMUNITY_Community 162|Community 162]]
- [[_COMMUNITY_Community 163|Community 163]]
- [[_COMMUNITY_Community 164|Community 164]]
- [[_COMMUNITY_Community 165|Community 165]]
- [[_COMMUNITY_Community 166|Community 166]]
- [[_COMMUNITY_Community 167|Community 167]]
- [[_COMMUNITY_Community 168|Community 168]]
- [[_COMMUNITY_Community 169|Community 169]]
- [[_COMMUNITY_Community 170|Community 170]]
- [[_COMMUNITY_Community 171|Community 171]]
- [[_COMMUNITY_Community 172|Community 172]]
- [[_COMMUNITY_Community 173|Community 173]]
- [[_COMMUNITY_Community 174|Community 174]]
- [[_COMMUNITY_Community 175|Community 175]]
- [[_COMMUNITY_Community 176|Community 176]]
## God Nodes (most connected - your core abstractions)
1. `PlayerManager` - 51 edges
2. `MediaUiModel` - 41 edges
3. `JellyfinApiClient` - 39 edges
4. `Icon` - 38 edges
5. `FakeDeviceProfileCapabilities` - 34 edges
6. `OfflineLocalMediaRepository` - 32 edges
7. `AndroidDeviceProfileCapabilities` - 32 edges
8. `Route` - 31 edges
9. `DeviceProfileCapabilities` - 30 edges
10. `OfflineRoomMediaLocalDataSource` - 30 edges
## Surprising Connections (you probably didn't know these)
- `tvEpisodeSection()` --calls--> `SeriesDto` [INFERRED]
app-tv/src/main/java/hu/bbara/purefin/navigation/TvRouteEntryBuilder.kt → core/src/main/java/hu/bbara/purefin/core/navigation/SeriesDto.kt
- `PosterCardContent()` --calls--> `UnwatchedEpisodeBadge()` [INFERRED]
app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt → core-ui/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt
- `PosterCardContent()` --calls--> `WatchStateBadge()` [INFERRED]
app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt → core-ui/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt
- `PosterCardContent()` --calls--> `PurefinAsyncImage()` [INFERRED]
app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt → core-ui/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt
- `TvMediaDetailBodyBox()` --calls--> `PurefinAsyncImage()` [INFERRED]
app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailShell.kt → core-ui/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt
## Import Cycles
- None detected.
## Hyperedges (group relationships)
- **Purefin Tech Stack** — readme_kotlin, readme_jetpack_compose, readme_media3_exoplayer, readme_hilt, readme_room_database, readme_datastore, readme_coil, readme_jellyfin_core_sdk, readme_okhttp, readme_androidx_navigation_3, readme_kotlin_serialization [INFERRED 0.95]
- **Purefin Features** — readme_video_playback_queue, readme_continue_watching, readme_user_authentication, readme_smart_downloads, readme_centralized_subtitles_management [INFERRED 0.95]
- **Purefin Development Conventions** — agents_material_design_3, agents_conventional_commits, agents_preferred_workflow [INFERRED 0.85]
- **Android TV App Launcher Icon Variants** — app-tv_src_main_res_mipmap_purefin_logo [INFERRED 0.95]
## Communities (190 total, 18 thin omitted)
### Community 0 - "Playback Profile Family"
Cohesion: 0.05
Nodes (17): PlaybackProfileFamilyModule, TvPlaybackProfileFamilyModule, PlaybackProfileFamily, AndroidDeviceProfileCapabilities, Av1ProfileLevels, DeviceProfileCapabilities, DolbyVisionProfiles, Boolean (+9 more)
### Community 1 - "Media Segment Model"
Cohesion: 0.05
Nodes (21): MediaSegment, SegmentType, List, MediaSegmentListener, MediaSegmentManager, Boolean, Flow, List (+13 more)
### Community 2 - "Download Controller"
Cohesion: 0.06
Nodes (28): Download, DownloadControllerModule, Boolean, Float, Flow, List, Map, MediaItem (+20 more)
### Community 3 - "Download Media Source"
Cohesion: 0.05
Nodes (29): DownloadMediaSourceResolver, EpisodeDownloadSource, Boolean, Int, List, Set, UUID, MovieDownloadSource (+21 more)
### Community 4 - "Jellyfin Playback Profiles"
Cohesion: 0.06
Nodes (21): CodecProfile, create(), generateCodecProfile(), JellyfinAndroidMobileProfileConfig, DeviceProfile, Set, String, codecRangeTypes() (+13 more)
### Community 5 - "Playable Media Model"
Cohesion: 0.08
Nodes (27): Episode, List, Long, MediaItem, UUID, Movie, PlayableMedia, Series (+19 more)
### Community 6 - "Season & Series Model"
Cohesion: 0.09
Nodes (26): Season, formatReleaseDate(), formatRuntime(), Episode, Int, Long, Movie, Series (+18 more)
### Community 7 - "Login Screen"
Cohesion: 0.09
Nodes (37): Modifier, LoginContent(), LoginPhaseContent(), ServerSearchContent(), Modifier, LoginScreen(), Composable, FocusRequester (+29 more)
### Community 8 - "Playback Progress Reporter"
Cohesion: 0.09
Nodes (19): Boolean, Long, UUID, PlaybackProgressReporter, PlaybackReportContext, Boolean, Job, Long (+11 more)
### Community 9 - "TV Home Content Tests"
Cohesion: 0.17
Nodes (12): Double, Episode, Float, List, Map, Movie, String, UUID (+4 more)
### Community 10 - "Offline Media Repository"
Cohesion: 0.11
Nodes (12): Boolean, Double, Episode, Flow, List, Long, Map, Movie (+4 more)
### Community 11 - "TV Hero Tests"
Cohesion: 0.10
Nodes (14): Boolean, Double, Episode, Movie, Series, TvFocusedItemHeroTest, Boolean, Movie (+6 more)
### Community 12 - "Track Mapper"
Cohesion: 0.12
Nodes (20): Boolean, Int, String, TrackMapper, TrackSelectionState, addRangeTypeIfSupported(), Audio, Codec (+12 more)
### Community 13 - "Jellyfin API Client"
Cohesion: 0.21
Nodes (10): JellyfinApiClient, BaseItemDto, DeviceProfile, Int, List, Set, T, UUID (+2 more)
### Community 14 - "Composite Media Repository"
Cohesion: 0.13
Nodes (13): CompositeLocalMediaRepository, Boolean, Double, Episode, Flow, Long, Map, Movie (+5 more)
### Community 15 - "Logging & Logger"
Cohesion: 0.15
Nodes (13): FileLogTree, Boolean, Context, File, Int, List, String, Throwable (+5 more)
### Community 16 - "In-Memory App Content"
Cohesion: 0.15
Nodes (12): HomeContentSnapshot, HomeRefreshFailedException, InMemoryAppContentRepository, Job, List, Map, StateFlow, String (+4 more)
### Community 17 - "TV Nav Drawer Tests"
Cohesion: 0.11
Nodes (19): TvNavigationDrawerTest, TvDrawerDestinationItem, Boolean, List, Modifier, ProvideTvDrawerTheme(), TvDrawerHeader(), TvNavigationDrawer() (+11 more)
### Community 18 - "Authentication Repository"
Cohesion: 0.13
Nodes (9): JellyfinServerCandidate, Boolean, Job, List, StateFlow, String, LoginViewModel, Phase (+1 more)
### Community 19 - "Home Refresh Side Effect"
Cohesion: 0.14
Nodes (9): HomeRefreshSideEffect, HomeRefreshSideEffectModule, SyncSmartDownloadsHomeRefreshSideEffect, Boolean, Double, Long, LocalPlaybackPosition, RemotePlaybackPosition (+1 more)
### Community 20 - "Player UI Models"
Cohesion: 0.11
Nodes (6): PlaylistElementUiModel, Job, StateFlow, String, UUID, PlayerViewModel
### Community 21 - "Episode & Series Feature"
Cohesion: 0.09
Nodes (24): Conventional Commits, Material Design 3, Preferred Workflow, Android, Android TV, AndroidX Navigation 3, APK Version Manager, Centralized Subtitles Management (+16 more)
### Community 22 - "Section Header UI"
Cohesion: 0.15
Nodes (8): Boolean, Episode, Job, List, Series, StateFlow, UUID, SeriesViewModel
### Community 23 - "Home Screen UI"
Cohesion: 0.09
Nodes (18): Modifier, String, SectionHeader(), ContinueWatchingSection(), List, Modifier, HomeContent(), Boolean (+10 more)
### Community 24 - "Player Screen UI"
Cohesion: 0.11
Nodes (19): DefaultTopBar(), DefaultTopBarIconButton(), DefaultTopBarSearchButton(), DefaultTopBarTextButton(), Boolean, ImageVector, Modifier, String (+11 more)
### Community 25 - "Settings Screen UI"
Cohesion: 0.09
Nodes (19): List, Long, Modifier, PlayerSeekBar(), Boolean, FocusRequester, List, Long (+11 more)
### Community 26 - "Room Database Layer"
Cohesion: 0.10
Nodes (19): BooleanSettingItem(), Boolean, Modifier, String, Modifier, String, ReadOnlySettingItem(), Modifier (+11 more)
### Community 27 - "Playback Profile Policy"
Cohesion: 0.16
Nodes (8): EpisodeDao, Boolean, Double, Flow, Int, List, UUID, EpisodeEntity
### Community 28 - "Media Detail Scaffold"
Cohesion: 0.16
Nodes (20): Activity, applyBrightness(), formatSeekDelta(), HiddenSeekTimeline(), Boolean, Float, List, Long (+12 more)
### Community 29 - "Download Service"
Cohesion: 0.21
Nodes (21): Dp, Modifier, String, MarkAsWatched, MediaDetailActions(), MediaDetailActionsUiModel, MediaDetailCastRow(), MediaDetailCastUiModel (+13 more)
### Community 30 - "Media Detail Components"
Cohesion: 0.13
Nodes (15): DownloadServiceEntryPoint, Context, DownloadManager, DownloadNotificationHelper, DownloadRequest, Int, Long, String (+7 more)
### Community 31 - "TV Navigation Module"
Cohesion: 0.22
Nodes (19): Icon, GenreChip(), GenreSelector(), Boolean, Composable, List, Modifier, Set (+11 more)
### Community 32 - "Media Metadata Updater"
Cohesion: 0.20
Nodes (11): EntryProviderScope, Unit, TvNavigationModule, tvEpisodeSection(), tvHomeSection(), tvLibrarySection(), tvLoginSection(), tvMovieSection() (+3 more)
### Community 33 - "Poster Card UI"
Cohesion: 0.14
Nodes (10): Boolean, Double, Long, UUID, MediaMetadataUpdater, JellyfinMediaMetadataUpdaterImpl, Boolean, Double (+2 more)
### Community 34 - "Series Screen UI"
Cohesion: 0.12
Nodes (15): Dp, Float, List, Modifier, PaddingValues, String, TextStyle, MediaImageCard() (+7 more)
### Community 35 - "Settings ViewModel"
Cohesion: 0.22
Nodes (17): borderIfFocused(), CastRow(), Boolean, Color, Episode, FocusRequester, List, Modifier (+9 more)
### Community 36 - "Library Screen UI"
Cohesion: 0.23
Nodes (15): BooleanSetting, DropdownSetting, Boolean, Double, String, T, RangeSetting, ReadOnlySetting (+7 more)
### Community 37 - "Movie Screen UI"
Cohesion: 0.14
Nodes (10): List, Modifier, LibraryPosterGrid(), LibraryScreen(), Boolean, List, StateFlow, UUID (+2 more)
### Community 38 - "Media Detail Shell"
Cohesion: 0.11
Nodes (15): TopAppBarScrollBehavior, MovieTopBar(), CircularIconButton(), Color, Dp, Float, ImageVector, Modifier (+7 more)
### Community 39 - "Community 39"
Cohesion: 0.22
Nodes (16): calculateScrollDistance(), Any, Float, Modifier, String, MediaDetailOverviewSection(), MediaDetailPlaybackSection(), MediaDetailSectionTitle() (+8 more)
### Community 40 - "Community 40"
Cohesion: 0.14
Nodes (15): Float, Modifier, String, TvFocusedItemHero(), TvHomeHeroBackdrop(), TvHomeHeroMetadataRow(), Boolean, Modifier (+7 more)
### Community 41 - "Community 41"
Cohesion: 0.25
Nodes (17): Boolean, ClosedFloatingPointRange, Double, List, Modifier, String, T, TvBooleanSettingItem() (+9 more)
### Community 42 - "Community 42"
Cohesion: 0.20
Nodes (8): Episode, Flow, Map, Movie, Series, StateFlow, UUID, LocalMediaRepository
### Community 43 - "Community 43"
Cohesion: 0.15
Nodes (5): AppViewModel, Boolean, StateFlow, String, UUID
### Community 44 - "Community 44"
Cohesion: 0.15
Nodes (11): HomeLibrarySettingsProvider, Flow, List, Flow, List, LogoutSettingsProvider, SettingGroup, Flow (+3 more)
### Community 45 - "Community 45"
Cohesion: 0.16
Nodes (7): Boolean, Double, Flow, List, UUID, MovieDao, MovieEntity
### Community 46 - "Community 46"
Cohesion: 0.18
Nodes (6): Flow, Int, List, UUID, SeriesDao, SeriesEntity
### Community 47 - "Community 47"
Cohesion: 0.18
Nodes (14): Boolean, Dp, Int, Modifier, PosterCard(), PosterCardContent(), Boolean, Dp (+6 more)
### Community 48 - "Community 48"
Cohesion: 0.19
Nodes (16): AppScreen(), appTabIndex(), appTabMetadata(), AppTabRoute, Downloads, Home, Any, Int (+8 more)
### Community 49 - "Community 49"
Cohesion: 0.15
Nodes (11): List, Modifier, LibrariesContent(), Modifier, LibraryListItem(), TvLibrariesOverviewScreenTest, List, Modifier (+3 more)
### Community 50 - "Community 50"
Cohesion: 0.15
Nodes (11): FocusRequester, Modifier, Movie, TvMovieHeroSection(), Boolean, Double, Float, String (+3 more)
### Community 51 - "Community 51"
Cohesion: 0.20
Nodes (15): Boolean, ImageVector, Int, Modifier, String, TvIconButton(), Boolean, Modifier (+7 more)
### Community 52 - "Community 52"
Cohesion: 0.32
Nodes (16): Array, androidAudioCodecName(), AndroidCodecCatalog, androidVideoCodecName(), androidVideoProfileName(), avcProfileName(), create(), h263ProfileName() (+8 more)
### Community 53 - "Community 53"
Cohesion: 0.18
Nodes (10): CacheEvictor, DownloadModule, DownloadModuleFactories, CacheDataSource, Context, DataSource, DownloadManager, DownloadNotificationHelper (+2 more)
### Community 54 - "Community 54"
Cohesion: 0.14
Nodes (10): Settings, Flow, SettingsRepository, bindSettingsRepository(), Context, DataStore, SettingsModule, InputStream (+2 more)
### Community 55 - "Community 55"
Cohesion: 0.20
Nodes (10): create(), Movie, Series, String, SearchResult, List, Set, StateFlow (+2 more)
### Community 56 - "Community 56"
Cohesion: 0.19
Nodes (7): AppUpdateInstaller, AndroidAppUpdateInstaller, File, Int, Long, String, IntentSender
### Community 57 - "Community 57"
Cohesion: 0.19
Nodes (8): DefaultNavigationManager, SharedFlow, Navigate, NavigationCommand, NavigationManager, Pop, ReplaceAll, NavigationManagerModule
### Community 58 - "Community 58"
Cohesion: 0.20
Nodes (13): EntryProviderScope, Unit, NavigationModule, EpisodeRoute, Home, HomeSearchRoute, LibraryRoute, LoginRoute (+5 more)
### Community 59 - "Community 59"
Cohesion: 0.22
Nodes (15): CastRow(), DownloadOptionRow(), DownloadOptionsBottomSheet(), EpisodeCard(), EpisodeCarousel(), Boolean, Episode, ImageVector (+7 more)
### Community 60 - "Community 60"
Cohesion: 0.19
Nodes (6): Episode, List, Movie, Series, UUID, OfflineMediaManager
### Community 61 - "Community 61"
Cohesion: 0.22
Nodes (8): InMemoryLocalMediaRepository, Episode, Flow, List, Map, Movie, Series, StateFlow
### Community 62 - "Community 62"
Cohesion: 0.18
Nodes (7): Flow, List, LibraryDao, LibraryEntity, LibraryWithContent, SeasonWithEpisodes, SeriesWithSeasonsAndEpisodes
### Community 63 - "Community 63"
Cohesion: 0.21
Nodes (5): Int, List, UUID, SeasonDao, SeasonEntity
### Community 64 - "Community 64"
Cohesion: 0.20
Nodes (8): Boolean, Bundle, EntryProviderScope, OkHttpClient, Set, Unit, PurefinActivity, ExitAppDialog()
### Community 65 - "Community 65"
Cohesion: 0.13
Nodes (11): DownloadingItemRow(), Modifier, Modifier, NextEpisodeOverlay(), ContentScale, ActiveDownloadItem, Any, ImageVector (+3 more)
### Community 66 - "Community 66"
Cohesion: 0.13
Nodes (12): Modifier, String, SuggestionCard(), List, Modifier, SuggestionsSection(), Color, Dp (+4 more)
### Community 67 - "Community 67"
Cohesion: 0.35
Nodes (13): Float, List, Modifier, String, UUID, TvContinueWatchingSection(), TvHomeLandscapeCard(), tvHomeLibraryFirstItemTag() (+5 more)
### Community 68 - "Community 68"
Cohesion: 0.14
Nodes (9): ApplicationInfo, AppVersionProvider, Long, String, AndroidAppVersionProvider, Long, String, AppUpdateModule (+1 more)
### Community 69 - "Community 69"
Cohesion: 0.17
Nodes (7): AppUpdateController, Boolean, Flow, List, SharedFlow, StateFlow, String
### Community 70 - "Community 70"
Cohesion: 0.17
Nodes (8): UserSession, Context, DataStore, UserSessionModule, InputStream, OutputStream, UserSessionSerializer, Serializer
### Community 71 - "Community 71"
Cohesion: 0.20
Nodes (6): Boolean, Flow, List, UUID, SmartDownloadDao, SmartDownloadEntity
### Community 72 - "Community 72"
Cohesion: 0.26
Nodes (13): BottomSection(), formatTime(), Boolean, Dp, Int, Long, Modifier, String (+5 more)
### Community 73 - "Community 73"
Cohesion: 0.16
Nodes (11): Modifier, PlayerLoadingErrorEndCard(), Modifier, TvPlayerLoadingErrorEndCard(), PlayerUiState, Boolean, Dp, Float (+3 more)
### Community 74 - "Community 74"
Cohesion: 0.23
Nodes (7): Boolean, Bundle, EntryProviderScope, OkHttpClient, Set, Unit, TvActivity
### Community 75 - "Community 75"
Cohesion: 0.22
Nodes (12): Boolean, Color, FocusRequester, Modifier, String, TvPlayerQueuePanel(), TvQueueArtwork(), TvQueueRowCard() (+4 more)
### Community 76 - "Community 76"
Cohesion: 0.18
Nodes (6): Boolean, Flow, List, UUID, SmartDownloadStore, OfflineBindingsModule
### Community 77 - "Community 77"
Cohesion: 0.21
Nodes (5): Boolean, Double, Long, UUID, RuntimeException
### Community 78 - "Community 78"
Cohesion: 0.25
Nodes (5): JellyfinAuthenticationRepository, AuthenticationResult, Boolean, Flow, String
### Community 79 - "Community 79"
Cohesion: 0.21
Nodes (4): Context, MediaDatabaseModule, OfflineMediaDatabase, RoomDatabase
### Community 80 - "Community 80"
Cohesion: 0.32
Nodes (12): androidx, formatTime(), handleExpandPlaylistKey(), Boolean, FocusRequester, Long, Modifier, String (+4 more)
### Community 81 - "Community 81"
Cohesion: 0.21
Nodes (7): TrackPreferences, Context, DataStore, TrackPreferencesModule, InputStream, OutputStream, TrackPreferencesSerializer
### Community 82 - "Community 82"
Cohesion: 0.21
Nodes (4): QuickConnectSession, AuthenticationResult, String, RecommendedServerInfo
### Community 83 - "Community 83"
Cohesion: 0.24
Nodes (7): DownloadModuleTest, FakeDataSourceFactory, Any, Boolean, CacheDataSource, String, DataSource
### Community 84 - "Community 84"
Cohesion: 0.29
Nodes (12): CachedCastMember, CachedEpisode, CachedLibrary, CachedMediaItem, CachedMovie, CachedSeries, toCachedCastMember(), toCachedEpisode() (+4 more)
### Community 85 - "Community 85"
Cohesion: 0.29
Nodes (4): CastMemberDao, List, UUID, CastMemberEntity
### Community 86 - "Community 86"
Cohesion: 0.21
Nodes (9): action, appRouteEntryBuilder(), Unit, rememberNotificationPermissionGate(), Modifier, Movie, MovieScreen(), MovieScreenInternal() (+1 more)
### Community 87 - "Community 87"
Cohesion: 0.20
Nodes (5): Bundle, PlayerActivity, AppTheme(), Boolean, ComponentActivity
### Community 88 - "Community 88"
Cohesion: 0.27
Nodes (11): homeMediaSharedBounds(), homeMediaSharedBoundsDestination(), homeMediaSharedBoundsKey(), homeMediaSharedBoundsSource(), isHomeMediaSharedBoundsTransitionActive(), Boolean, Modifier, String (+3 more)
### Community 89 - "Community 89"
Cohesion: 0.27
Nodes (10): EpisodeTopBar(), EpisodeTopBarShortcut, String, TopAppBarScrollBehavior, Series, EpisodeScreen(), EpisodeScreenInternal(), Episode (+2 more)
### Community 90 - "Community 90"
Cohesion: 0.17
Nodes (9): Modifier, String, SearchMessage(), Boolean, Dp, Modifier, SearchOverlay(), Modifier (+1 more)
### Community 91 - "Community 91"
Cohesion: 0.20
Nodes (9): Boolean, Modifier, String, PlayerQueuePanel(), QueueRow(), Modifier, TvNextEpisodeOverlay(), Unit (+1 more)
### Community 92 - "Community 92"
Cohesion: 0.41
Nodes (10): createPlaceholder(), EpisodeUiModel, Boolean, Float, Int, String, UUID, MediaUiModel (+2 more)
### Community 93 - "Community 93"
Cohesion: 0.24
Nodes (7): Double, Movie, MovieScreenContentTest, Modifier, Movie, TvMovieScreen(), TvMovieScreenContent()
### Community 94 - "Community 94"
Cohesion: 0.17
Nodes (10): Episode, FocusRequester, Modifier, String, TvEpisodeHeroSection(), Color, List, Modifier (+2 more)
### Community 95 - "Community 95"
Cohesion: 0.18
Nodes (10): calculateScrollDistance(), Float, tvHomeColumnBringIntoViewSpec(), List, Map, Modifier, PaddingValues, UUID (+2 more)
### Community 96 - "Community 96"
Cohesion: 0.27
Nodes (11): handleTvPlayerRootKeyEvent(), HiddenTvSeekTimeline(), Boolean, List, Long, Modifier, String, TvPlayerClock() (+3 more)
### Community 97 - "Community 97"
Cohesion: 0.23
Nodes (11): Library, Episode, Movie, Series, UUID, toCastMember(), toEpisode(), toLibrary() (+3 more)
### Community 98 - "Community 98"
Cohesion: 0.35
Nodes (10): Cached, CacheEntry, Join, String, T, Resolution, SingleFlight, Start (+2 more)
### Community 99 - "Community 99"
Cohesion: 0.24
Nodes (5): Boolean, Flow, String, UUID, UserSessionRepository
### Community 100 - "Community 100"
Cohesion: 0.20
Nodes (4): Boolean, Movie, StateFlow, MovieScreenViewModel
### Community 101 - "Community 101"
Cohesion: 0.24
Nodes (5): DataStoreUserSessionRepository, Boolean, Flow, String, UUID
### Community 102 - "Community 102"
Cohesion: 0.20
Nodes (7): HomeCache, HomeCacheModule, Context, DataStore, HomeCacheSerializer, InputStream, OutputStream
### Community 103 - "Community 103"
Cohesion: 0.22
Nodes (6): UpdateAvailableDialog(), AppUpdateInfo, String, AppUpdateManifest, AppUpdateRepository, String
### Community 104 - "Community 104"
Cohesion: 0.51
Nodes (10): AudioSelectionButton(), Boolean, List, Modifier, String, QualitySelectionButton(), SubtitlesSelectionButton(), TrackOptionItem() (+2 more)
### Community 105 - "Community 105"
Cohesion: 0.29
Nodes (6): empty(), String, MediaTrackPreferences, Flow, String, TrackPreferencesRepository
### Community 106 - "Community 106"
Cohesion: 0.31
Nodes (4): AuthenticationRepository, Boolean, Flow, String
### Community 107 - "Community 107"
Cohesion: 0.20
Nodes (5): DownloadsViewModel, String, UUID, SeriesDto, ViewModel
### Community 108 - "Community 108"
Cohesion: 0.25
Nodes (4): Set, String, UUID, SearchViewModel
### Community 109 - "Community 109"
Cohesion: 0.20
Nodes (8): Boolean, Double, List, SharedFlow, StateFlow, String, T, SettingsViewModel
### Community 110 - "Community 110"
Cohesion: 0.22
Nodes (4): Long, Job, Long, SeekByCollector
### Community 111 - "Community 111"
Cohesion: 0.18
Nodes (9): DownloadActionButton(), Dp, Modifier, Color, Dp, Float, ImageVector, Modifier (+1 more)
### Community 112 - "Community 112"
Cohesion: 0.20
Nodes (8): AppBottomBar(), Boolean, Int, Boolean, Int, List, Modifier, LibrariesScreen()
### Community 113 - "Community 113"
Cohesion: 0.27
Nodes (4): EpisodeScreenContentTest, Double, Episode, CastMember
### Community 114 - "Community 114"
Cohesion: 0.38
Nodes (9): defaultSeason(), focusTargetEpisodeId(), Episode, Modifier, Series, UUID, nextUpEpisode(), TvSeriesScreen() (+1 more)
### Community 115 - "Community 115"
Cohesion: 0.33
Nodes (5): AudioTrackProperties, SubtitleTrackProperties, Int, List, TrackMatcher
### Community 116 - "Community 116"
Cohesion: 0.24
Nodes (4): EpisodeScreenViewModel, Boolean, Episode, StateFlow
### Community 118 - "Community 118"
Cohesion: 0.22
Nodes (5): AppUpdateViewModel, Boolean, SharedFlow, StateFlow, String
### Community 119 - "Community 119"
Cohesion: 0.22
Nodes (6): JellyfinAuthInterceptor, String, JellyfinNetworkModule, OkHttpClient, Interceptor, Response
### Community 120 - "Community 120"
Cohesion: 0.31
Nodes (9): Boolean, Color, Dp, Float, Modifier, String, TextUnit, MediaResumeButton() (+1 more)
### Community 121 - "Community 121"
Cohesion: 0.24
Nodes (3): Boolean, Long, PlayMethod
### Community 122 - "Community 122"
Cohesion: 0.22
Nodes (5): LogUploadSettingsModule, Flow, List, String, LogUploadSettingsProvider
### Community 123 - "Community 123"
Cohesion: 0.27
Nodes (5): Boolean, Flow, List, UUID, RoomSmartDownloadStore
### Community 124 - "Community 124"
Cohesion: 0.22
Nodes (7): HomeBrowseCard(), Modifier, String, Color, Int, Modifier, UnwatchedEpisodeBadge()
### Community 125 - "Community 125"
Cohesion: 0.28
Nodes (7): Boolean, Composable, Modifier, Unit, PersistentOverlayContainer(), PersistentOverlayController, rememberPersistentOverlayController()
### Community 126 - "Community 126"
Cohesion: 0.39
Nodes (8): canPerform(), Boolean, Modifier, Series, MarkSeriesAsWatchedConfirmationDialog(), requiresNotificationPermission(), SeriesScreen(), SeriesScreenInternal()
### Community 127 - "Community 127"
Cohesion: 0.28
Nodes (5): List, Set, StateFlow, String, SearchManager
### Community 128 - "Community 128"
Cohesion: 0.33
Nodes (4): ArtworkKind, ImageUrlBuilder, String, UUID
### Community 129 - "Community 129"
Cohesion: 0.28
Nodes (6): UUID, UuidSerializer, Decoder, Encoder, KSerializer, SerialDescriptor
### Community 130 - "Community 130"
Cohesion: 0.22
Nodes (8): Boolean, Color, Dp, Int, Modifier, String, TextUnit, MediaSynopsis()
### Community 131 - "Community 131"
Cohesion: 0.36
Nodes (6): Boolean, Double, Flow, String, T, SettingsRepositoryImpl
### Community 132 - "Community 132"
Cohesion: 0.25
Nodes (6): ContentBadge(), Color, Modifier, String, HomeEmptyState(), Modifier
### Community 133 - "Community 133"
Cohesion: 0.25
Nodes (6): DownloadsContent(), Modifier, DownloadsScreen(), Boolean, Int, Modifier
### Community 134 - "Community 134"
Cohesion: 0.25
Nodes (7): HomeScreen(), Boolean, Int, List, Map, Modifier, UUID
### Community 136 - "Community 136"
Cohesion: 0.36
Nodes (7): GhostTextButton(), Dp, FocusRequester, Modifier, String, MediaDetailsTopBar(), MediaDetailsTopBarShortcut
### Community 137 - "Community 137"
Cohesion: 0.25
Nodes (5): HomeRepository, List, Map, StateFlow, UUID
### Community 138 - "Community 138"
Cohesion: 0.39
Nodes (3): HomeRefreshCoordinator, Boolean, Unit
### Community 139 - "Community 139"
Cohesion: 0.25
Nodes (7): CircularTextButton(), Color, Dp, Float, Int, Modifier, String
### Community 140 - "Community 140"
Cohesion: 0.25
Nodes (7): Color, Float, ImageVector, Int, Modifier, String, PurefinIconButton()
### Community 141 - "Community 141"
Cohesion: 0.29
Nodes (3): PurefinApplication, TvApplication, Application
### Community 142 - "Community 142"
Cohesion: 0.29
Nodes (6): Dp, Float, ImageVector, Modifier, String, PlayerAdjustmentIndicator()
### Community 143 - "Community 143"
Cohesion: 0.38
Nodes (4): BroadcastReceiver, AppUpdateInstallReceiver, Context, Intent
### Community 144 - "Community 144"
Cohesion: 0.48
Nodes (6): EpisodeMedia, Media, MovieMedia, SeasonMedia, SeriesMedia, toMedia()
### Community 146 - "Community 146"
Cohesion: 0.33
Nodes (6): Boolean, Color, Int, Modifier, WatchStateBadge(), WatchStateBadgePreview()
### Community 147 - "Community 147"
Cohesion: 0.48
Nodes (6): Color, ImageVector, Modifier, String, MediaPlaybackSettings(), MediaSettingDropdown()
### Community 148 - "Community 148"
Cohesion: 0.48
Nodes (6): Color, Float, Modifier, PurefinWaitingScreen(), WaitingDot(), WaitingDots()
### Community 149 - "Community 149"
Cohesion: 0.33
Nodes (5): DropdownSettingItem(), List, Modifier, String, T
### Community 150 - "Community 150"
Cohesion: 0.33
Nodes (5): ClosedFloatingPointRange, Double, Modifier, String, RangeSettingItem()
### Community 151 - "Community 151"
Cohesion: 0.60
Nodes (5): Downloaded, Downloading, DownloadState, Failed, NotDownloaded
### Community 152 - "Community 152"
Cohesion: 0.33
Nodes (4): Application, DataSource, Player, VideoPlayerModule
### Community 153 - "Community 153"
Cohesion: 0.33
Nodes (3): CodecDebugHelper, Boolean, String
### Community 154 - "Community 154"
Cohesion: 0.47
Nodes (3): String, UUID, UuidConverters
### Community 155 - "Community 155"
Cohesion: 0.40
Nodes (3): HorizontalSeekGestureHelper, Float, Long
### Community 156 - "Community 156"
Cohesion: 0.40
Nodes (4): DragDirection, Boolean, Modifier, PlayerGesturesLayer()
### Community 159 - "Community 159"
Cohesion: 0.40
Nodes (3): OkHttpClient, OkHttpDataSource, PlaybackNetworkModule
### Community 160 - "Community 160"
Cohesion: 0.40
Nodes (4): Color, Modifier, String, MediaMetaChip()
### Community 162 - "Community 162"
Cohesion: 0.50
Nodes (3): Modifier, String, StringSettingItem()
### Community 163 - "Community 163"
Cohesion: 0.50
Nodes (3): hslColor(), Color, Float
### Community 164 - "Community 164"
Cohesion: 0.50
Nodes (3): hslColor(), Color, Float
## Knowledge Gaps
- **37 isolated node(s):** `ColorFamily`, `DragDirection`, `SeriesDownloadOption`, `Episode`, `LibraryKind` (+32 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **18 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Icon` connect `TV Navigation Module` to `Community 132`, `Community 133`, `Login Screen`, `Community 142`, `TV Nav Drawer Tests`, `Community 146`, `Community 147`, `Community 148`, `Home Screen UI`, `Player Screen UI`, `Download Service`, `Series Screen UI`, `Settings ViewModel`, `Media Detail Shell`, `Community 41`, `Community 49`, `Community 51`, `Community 59`, `Community 65`, `Community 75`, `Community 90`, `Community 91`, `Community 96`, `Community 104`, `Community 112`, `Community 120`?**
_High betweenness centrality (0.215) - this node is a cross-community bridge._
- **Why does `TrackOption` connect `Community 104` to `Media Segment Model`, `Track Mapper`, `Community 115`, `Player UI Models`, `Settings Screen UI`?**
_High betweenness centrality (0.174) - this node is a cross-community bridge._
- **Why does `Season` connect `Season & Series Model` to `Settings ViewModel`, `Community 135`, `Offline Media Repository`, `Community 114`, `Community 59`, `Community 60`, `Download Service`?**
_High betweenness centrality (0.130) - this node is a cross-community bridge._
- **Are the 41 inferred relationships involving `RoundedCornerShape` (e.g. with `MediaImageCard()` and `PosterCardContent()`) actually correct?**
_`RoundedCornerShape` has 41 INFERRED edges - model-reasoned connections that need verification._
- **Are the 36 inferred relationships involving `Icon` (e.g. with `MediaImageCard()` and `SectionHeader()`) actually correct?**
_`Icon` has 36 INFERRED edges - model-reasoned connections that need verification._
- **What connects `ColorFamily`, `DragDirection`, `SeriesDownloadOption` to the rest of the system?**
_40 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Playback Profile Family` be split into smaller, more focused modules?**
_Cohesion score 0.054746835443037975 - nodes in this community are weakly interconnected._

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"nodes": [{"id": "agents_material_design_3", "label": "Material Design 3", "file_type": "rationale", "source_file": "AGENTS.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "agents_conventional_commits", "label": "Conventional Commits", "file_type": "rationale", "source_file": "AGENTS.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "agents_preferred_workflow", "label": "Preferred Workflow", "file_type": "rationale", "source_file": "AGENTS.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "readme_purefin", "target": "agents_material_design_3", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "AGENTS.md", "source_location": null, "weight": 1.0}, {"source": "readme_purefin", "target": "agents_conventional_commits", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "AGENTS.md", "source_location": null, "weight": 1.0}, {"source": "readme_purefin", "target": "agents_preferred_workflow", "relation": "conceptually_related_to", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "AGENTS.md", "source_location": null, "weight": 1.0}], "hyperedges": [{"id": "purefin_development_conventions", "label": "Purefin Development Conventions", "nodes": ["agents_material_design_3", "agents_conventional_commits", "agents_preferred_workflow"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.85, "source_file": "AGENTS.md"}]}

View File

@@ -0,0 +1 @@
{"nodes": [{"id": "app-tv_src_main_res_mipmap_purefin_logo", "label": "Purefin Logo (App Icon)", "file_type": "image", "source_file": "app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [], "hyperedges": [{"id": "app_tv_launcher_icons", "label": "Android TV App Launcher Icon Variants", "nodes": ["app-tv_src_main_res_mipmap_purefin_logo"], "relation": "form", "confidence": "INFERRED", "confidence_score": 0.95, "source_file": "app-tv/src/main/res/mipmap-hdpi/purefin_logo.webp"}]}

1
graphify-out/cache/stat-index.json vendored Normal file

File diff suppressed because one or more lines are too long

12
graphify-out/cost.json Normal file
View File

@@ -0,0 +1,12 @@
{
"runs": [
{
"date": "2026-07-04T03:02:52.409955+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 349
}
],
"total_input_tokens": 0,
"total_output_tokens": 0
}

307
graphify-out/graph.html Normal file

File diff suppressed because one or more lines are too long

81104
graphify-out/graph.json Normal file

File diff suppressed because it is too large Load Diff

1747
graphify-out/manifest.json Normal file

File diff suppressed because it is too large Load Diff