Compare commits

...

32 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
35 changed files with 84556 additions and 197 deletions

View File

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

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -46,7 +45,7 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
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.platform.LocalContext
import androidx.compose.ui.platform.testTag
@@ -81,7 +80,7 @@ import java.time.format.DateTimeFormatter
import java.util.Locale
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 TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@@ -165,6 +164,9 @@ fun TvPlayerScreen(
rootFocusRequester.requestFocus()
}
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
fun expandPlaylist() {
// TODO: focus management for playlist expansion
@@ -180,6 +182,14 @@ fun TvPlayerScreen(
trackPanelType = null
}
}
fun handleBack() {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> closePlaylist()
controlsVisible -> hideControls()
else -> onBack()
}
}
val showSkipIntroButton = !controlsVisible
@@ -227,50 +237,37 @@ fun TvPlayerScreen(
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> {
closePlaylist()
}
controlsVisible -> {
hideControls()
}
else -> onBack()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(rootFocusRequester)
.onPreviewKeyEvent { event ->
val handled = handleTvPlayerRootKeyEvent(
event = event,
controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls,
isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = {},
onHideControls = ::hideControls,
onTogglePlayback = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden resume/stop feedback.
resumeStopFeedbackCounter++
viewModel.togglePlayPause()
},
onSeekRelative = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden seek timeline.
hiddenSeekCounter++
viewModel.seekBy(it)
},
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
.onKeyEvent { event ->
if (event.key == Key.Back) {
if (event.type == KeyEventType.KeyDown) {
handleBack()
}
true
} else {
val handled = handleTvPlayerRootKeyEvent(
event = event,
controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls,
onTogglePlayback = {
resumeStopFeedbackCounter++
viewModel.togglePlayPause()
},
onSeekRelative = {
hiddenSeekCounter++
viewModel.seekBy(it)
},
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
}
handled
}
handled
}
.focusable()
) {
@@ -281,6 +278,8 @@ fun TvPlayerScreen(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
isFocusable = false
isFocusableInTouchMode = false
}
},
update = {
@@ -501,38 +500,12 @@ internal fun handleTvPlayerRootKeyEvent(
event: KeyEvent,
controlsVisible: Boolean,
popupVisible: Boolean,
isPlaylistExpanded: Boolean,
trackPanelType: TvTrackPanelType?,
onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit,
onTogglePlayback: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
): Boolean {
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) {
return when (event.key) {
Key.DirectionLeft -> {

View File

@@ -78,10 +78,10 @@ internal fun TvSeriesScreenContent(
var selectedSeasonId by remember(series.id, focusedSeasonId) {
mutableStateOf(defaultSeason?.id)
}
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
val initialFocusSeasonId = defaultSeason?.id
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
series.seasons.firstOrNull { it.id == seasonId }
series.allSeasons.firstOrNull { it.id == seasonId }
} ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
val seasonTabFocusRequester = remember { FocusRequester() }
@@ -120,7 +120,7 @@ internal fun TvSeriesScreenContent(
Spacer(modifier = Modifier.height(16.dp))
if (selectedSeason != null) {
TvSeasonTabs(
seasons = series.seasons,
seasons = series.allSeasons,
selectedSeason = selectedSeason,
selectedItemFocusRequester = seasonTabFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
@@ -156,10 +156,10 @@ internal fun TvSeriesScreenContent(
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
if (focusedSeasonId != null) {
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
}
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull()
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
}
private fun Season.nextUpEpisode(): Episode? {

View File

@@ -153,14 +153,20 @@ private fun TvSeasonTab(
) {
val scheme = MaterialTheme.colorScheme
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
.onFocusChanged { state ->
if (state.isFocused) onFocused()
if (state.isFocused) {
onFocused()
isFocused.value = true
} else {
isFocused.value = false
}
}
.clickable(onClick = onClick)
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
) {
TvText(
text = name,
@@ -169,13 +175,8 @@ private fun TvSeasonTab(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(indicatorColor)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
@@ -256,11 +257,9 @@ internal fun TvEpisodeCarousel(
Modifier
}
)
.then(
upFocusRequester?.let { requester ->
Modifier.focusProperties { up = requester }
} ?: Modifier
)
.then(upFocusRequester?.let { requester ->
Modifier.focusProperties { up = requester }
} ?: Modifier)
.then(
if (episode.id == targetEpisodeId) {
Modifier.testTag(SeriesNextUpEpisodeCardTag)
@@ -432,3 +431,15 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
// 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 {
namespace = "hu.bbara.purefin"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "hu.bbara.purefin"

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.provider.Settings
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -37,6 +38,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
@@ -65,8 +68,9 @@ import kotlin.math.abs
import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
/** Small negative band below zero that represents adaptive/auto brightness mode. */
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class)
@@ -88,7 +92,11 @@ fun PlayerScreen(
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
var isAutoBrightness by remember { mutableStateOf(false) }
var dragOvershoot by remember { mutableFloatStateOf(0f) }
var autoDragTick by remember { mutableStateOf(0) }
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
@@ -129,6 +137,61 @@ fun PlayerScreen(
toggleControlsVisibility()
hideControlsWithTimeout()
}
fun onBrightnessDragStart(isLeftSide: Boolean) {
if (isLeftSide) {
dragOvershoot = 0f
}
}
fun onBrightnessDragEnd() {
dragOvershoot = 0f
}
fun onBrightnessDrag(delta: Float) {
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
if (isAutoBrightness) {
if (diff > 0f) {
dragOvershoot += diff
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
isAutoBrightness = false
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
} else {
autoDragTick++
}
} else {
val newBrightness = brightness + diff
if (newBrightness <= 0f) {
brightness = 0f
applyBrightness(activity, brightness, isAuto = false)
dragOvershoot += newBrightness
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
isAutoBrightness = true
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = true)
}
} else {
brightness = newBrightness.coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
}
}
fun onVolumeDrag(delta: Float) {
val diff = -delta / 800f
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
restoreBrightness(activity, originalScreenBrightness)
}
Box(
@@ -145,6 +208,10 @@ fun PlayerScreen(
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
}
},
update = {
it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
},
onRelease = { playerView ->
playerView.player = null
},
@@ -160,20 +227,10 @@ fun PlayerScreen(
onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta ->
val diff = (-delta / 800f)
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
applyBrightness(activity, brightness)
},
onVerticalDragRight = { delta ->
val diff = (-delta / 800f)
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
},
onVerticalDragStart = ::onBrightnessDragStart,
onVerticalDragEnd = ::onBrightnessDragEnd,
onVerticalDragLeft = ::onBrightnessDrag,
onVerticalDragRight = ::onVolumeDrag,
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
horizontalSeekFeedback = deltaMs
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
@@ -221,23 +278,21 @@ fun PlayerScreen(
}
ValueChangeTimedVisibility(
value = brightness,
value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
hideAfterMillis = 800,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 20.dp)
) { currentBrightness ->
) { state ->
PlayerAdjustmentIndicator(
modifier = Modifier.align(Alignment.Center),
icon = Icons.Outlined.BrightnessMedium,
contentDescription = "Brightness",
value = currentBrightness,
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
value = state.brightness,
bottomText = if (state.isAuto) {
"Auto"
} else if (currentBrightness >= 0f) {
"${(currentBrightness * 100).roundToInt()}%"
} else {
null
"${(state.brightness * 100).roundToInt()}%"
}
)
}
@@ -425,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
}
}
private fun readCurrentBrightness(activity: Activity?): Float {
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else -1f
private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
val windowBrightness = activity?.window?.attributes?.screenBrightness
if (windowBrightness != null && windowBrightness >= 0f) {
return windowBrightness
}
return try {
val systemBrightness = Settings.System.getInt(
context.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
(systemBrightness / 255f).coerceIn(0f, 1f)
} catch (e: Settings.SettingNotFoundException) {
0.5f
}
}
private fun applyBrightness(activity: Activity?, value: Float) {
private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
params.screenBrightness = if (isAuto) -1f else brightness
activity.window.attributes = params
}
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = originalValue
activity.window.attributes = params
}

View File

@@ -30,6 +30,8 @@ fun PlayerGesturesLayer(
onDoubleTapLeft: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit,
onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
onVerticalDragEnd: () -> Unit = {},
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
currentPositionProvider: () -> Long,
@@ -79,6 +81,7 @@ fun PlayerGesturesLayer(
var isHorizontalDragActive = false
var lastPreviewDelta: Long? = null
var startPositionMs = 0L
var verticalDragStarted = false
drag(down.id) { change ->
val delta = change.positionChange()
@@ -115,6 +118,10 @@ fun PlayerGesturesLayer(
DragDirection.VERTICAL -> {
change.consume()
val isLeftSide = startX < size.width / 2
if (!verticalDragStarted) {
verticalDragStarted = true
onVerticalDragStart(isLeftSide)
}
if (isLeftSide) {
onVerticalDragLeft(delta.y)
} else {
@@ -134,6 +141,10 @@ fun PlayerGesturesLayer(
}
}
onHorizontalDragPreview(null, null)
if (verticalDragStarted) {
onVerticalDragEnd()
}
}
}
)

View File

@@ -149,12 +149,12 @@ private fun SeriesScreenInternal(
var showMarkAsWatchedDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
return series.allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.allSeasons.firstOrNull()
}
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
val selectedSeason =
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason?.episodes?.firstOrNull()
val playAction = remember(nextUpEpisode, offline) {
@@ -210,7 +210,7 @@ private fun SeriesScreenInternal(
) { _modifier ->
if (selectedSeason != null) {
SeasonTabs(
seasons = series.seasons,
seasons = series.allSeasons,
selectedSeason = selectedSeason,
onSelect = { selectedSeasonId = it.id },
modifier = _modifier

View File

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

View File

@@ -2,6 +2,10 @@ package hu.bbara.purefin.model
import java.util.UUID
val UNCATEGORIZED_SEASON_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000000")
val UNCATEGORIZED_SERIES_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000001")
const val UNCATEGORIZED_LABEL: String = "Uncategorized"
data class Series(
val id: UUID,
val libraryId: UUID,
@@ -12,5 +16,27 @@ data class Series(
val unwatchedEpisodeCount: Int,
val seasonCount: Int,
val seasons: List<Season>,
val uncategorizedEpisodes: List<Episode> = emptyList(),
val cast: List<CastMember>
)
) {
/**
* Real seasons plus a synthetic "Uncategorized" season appended at the end
* when [uncategorizedEpisodes] is non-empty. Used by the UI for the season
* tab list so uncategorized episodes are selectable like any other season.
*/
val allSeasons: List<Season>
get() = if (uncategorizedEpisodes.isEmpty()) {
seasons
} else {
seasons + Season(
id = UNCATEGORIZED_SEASON_ID,
seriesId = id,
name = UNCATEGORIZED_LABEL,
index = 0,
unwatchedEpisodeCount = uncategorizedEpisodes.count { !it.watched },
episodeCount = uncategorizedEpisodes.size,
episodes = uncategorizedEpisodes,
)
}
}

View File

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

View File

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

View File

@@ -43,8 +43,6 @@ class ProgressManager @Inject constructor(
combine(playbackState, progress, metadata) { state, prog, meta ->
Triple(state, prog, meta)
}.collect { (state, prog, meta) ->
lastPositionMs = prog.positionMs
lastDurationMs = prog.durationMs
isPaused = !state.isPlaying
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val nextPlaybackReportContext = meta.playbackReportContext
@@ -53,6 +51,9 @@ class ProgressManager @Inject constructor(
stopSession()
}
lastPositionMs = prog.positionMs
lastDurationMs = prog.durationMs
if (activeItemId == null && mediaId != null && !state.isEnded) {
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
} else if (activeItemId == mediaId) {
@@ -65,11 +66,21 @@ class ProgressManager @Inject constructor(
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
activeItemId = itemId
activePlaybackReportContext = reportContext
val isOffline = reportContext == null
report(itemId, positionMs, reportContext = reportContext, isStart = true)
progressJob = scope.launch {
while (isActive) {
delay(5000)
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,
) : ViewModel() {
companion object {
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
private const val TAG = "PlayerViewModel"
}

View File

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

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.concurrency.SingleFlight
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.converter.isUncategorizedEpisode
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.data.converter.toSeason
@@ -11,6 +12,7 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -82,7 +84,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
seriesState.update { current ->
val existing = current[series.id]
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) {
series.copy(seasons = existing.seasons)
series.copy(
seasons = existing.seasons,
uncategorizedEpisodes = existing.uncategorizedEpisodes,
)
} else {
series
}
@@ -124,7 +129,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
current + series.associateBy { it.id }.mapValues { (id, newSeries) ->
val existing = current[id]
if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) {
newSeries.copy(seasons = existing.seasons)
newSeries.copy(
seasons = existing.seasons,
uncategorizedEpisodes = existing.uncategorizedEpisodes,
)
} else {
newSeries
}
@@ -145,8 +153,16 @@ class InMemoryLocalMediaRepository @Inject constructor(
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
}
val existingSeasonsById = series.seasons.associateBy { it.id }
val updatedSeries = series.copy(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }.map { fresh ->
val existing = existingSeasonsById[fresh.id]
if (existing != null && existing.episodes.isNotEmpty()) {
fresh.copy(episodes = existing.episodes)
} else {
fresh
}
}
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
} catch (error: CancellationException) {
@@ -160,6 +176,11 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") {
try {
// The "Uncategorized" season is synthetic; its episodes are
// populated as a side effect of loading real seasons, so
// selecting that tab must not trigger a Jellyfin API call.
if (seasonId == UNCATEGORIZED_SEASON_ID) return@run
// Ensure the series and season is loaded
var series = seriesState.value[seriesId]
if (series == null) {
@@ -179,22 +200,35 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
val seriesName = seriesState.value[seriesId]?.name
val (categorized, uncategorized) = jellyfinApiClient
.getEpisodesInSeason(seriesId, seasonId)
.partition { !it.isUncategorizedEpisode() }
val categorizedEpisodes = categorized.map {
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
}
val uncategorizedEpisodes = uncategorized.map {
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
}
seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy(
seasons = currentSeries.seasons.map {
if (it.id == seasonId && it.episodes.isEmpty()) {
it.copy(episodes = episodes)
it.copy(episodes = categorizedEpisodes)
} else {
it
}
}
},
uncategorizedEpisodes = (
currentSeries.uncategorizedEpisodes + uncategorizedEpisodes
).distinctBy { it.id }
)
current + (updatedSeries.id to updatedSeries)
}
episodesState.update { current -> current + episodes.associateBy { it.id } }
episodesState.update { current ->
current + (categorizedEpisodes + uncategorizedEpisodes).associateBy { it.id }
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@@ -277,8 +311,21 @@ class InMemoryLocalMediaRepository @Inject constructor(
)
}
}
val uncategorizedEpisodes = if (
series.uncategorizedEpisodes.none { it.id == updatedEpisode.id }
) {
series.uncategorizedEpisodes
} else {
changed = true
series.uncategorizedEpisodes.map { episode ->
if (episode.id == updatedEpisode.id) updatedEpisode else episode
}
}
if (changed) {
current + (series.id to series.copy(seasons = seasons))
current + (series.id to series.copy(
seasons = seasons,
uncategorizedEpisodes = uncategorizedEpisodes,
))
} else {
current
}

View File

@@ -9,12 +9,15 @@ import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
import hu.bbara.purefin.model.UNCATEGORIZED_SERIES_ID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
fun BaseItemDto.toLibrary(serverUrl: String): Library? {
@@ -101,24 +104,38 @@ fun BaseItemDto.toSeason(): Season {
)
}
fun BaseItemDto.toEpisode(serverUrl: String): Episode {
/**
* An episode is considered "uncategorized" when it is missing any of the
* identity/season fields required to place it under a real season. Missing
* watch data ([userData]) is intentionally NOT a trigger — it is defaulted
* so the episode can still appear under its real season.
*/
fun BaseItemDto.isUncategorizedEpisode(): Boolean =
parentId == null || seriesId == null || seriesName == null ||
parentIndexNumber == null || indexNumber == null
fun BaseItemDto.toEpisode(
serverUrl: String,
fallbackSeriesId: UUID? = null,
fallbackSeriesName: String? = null,
): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seriesName = seriesName!!,
seasonId = parentId!!,
seasonIndex = parentIndexNumber!!,
id = id ?: error("Episode without id"),
seriesId = seriesId ?: fallbackSeriesId ?: UNCATEGORIZED_SERIES_ID,
seriesName = seriesName ?: fallbackSeriesName ?: "Unknown",
seasonId = parentId ?: UNCATEGORIZED_SEASON_ID,
seasonIndex = parentIndexNumber ?: 0,
title = name ?: "Unknown title",
index = indexNumber!!,
index = indexNumber ?: 0,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
progress = userData?.playedPercentage,
watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix,

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

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

@@ -13,6 +13,8 @@ import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.model.UNCATEGORIZED_LABEL
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.UUID
@@ -78,6 +80,23 @@ class OfflineRoomMediaLocalDataSource(
episodeDao.upsert(episode.toEntity())
}
}
if (series.uncategorizedEpisodes.isNotEmpty()) {
val unwatched = series.uncategorizedEpisodes.count { !it.watched }
val sentinelSeason = Season(
id = UNCATEGORIZED_SEASON_ID,
seriesId = series.id,
name = UNCATEGORIZED_LABEL,
index = 0,
unwatchedEpisodeCount = unwatched,
episodeCount = series.uncategorizedEpisodes.size,
episodes = series.uncategorizedEpisodes,
)
seasonDao.upsert(sentinelSeason.toEntity())
series.uncategorizedEpisodes.forEach { episode ->
episodeDao.upsert(episode.toEntity())
}
}
}
}
@@ -94,6 +113,19 @@ class OfflineRoomMediaLocalDataSource(
seriesDao.getById(episode.seriesId)
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
if (episode.seasonId == UNCATEGORIZED_SEASON_ID && seasonDao.getById(UNCATEGORIZED_SEASON_ID) == null) {
seasonDao.upsert(
SeasonEntity(
id = UNCATEGORIZED_SEASON_ID,
seriesId = episode.seriesId,
name = UNCATEGORIZED_LABEL,
index = 0,
unwatchedEpisodeCount = 0,
episodeCount = 0,
)
)
}
episodeDao.upsert(episode.toEntity())
}
}
@@ -165,12 +197,14 @@ class OfflineRoomMediaLocalDataSource(
}
suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
episodeEntity.toDomain()
return seasonDao.getBySeriesId(seriesId)
.filter { it.id != UNCATEGORIZED_SEASON_ID }
.map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
episodeEntity.toDomain()
}
seasonEntity.toDomain(episodes)
}
seasonEntity.toDomain(episodes)
}
}
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
@@ -277,18 +311,23 @@ class OfflineRoomMediaLocalDataSource(
cast = emptyList()
)
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
imageUrlPrefix = imageUrlPrefix,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
seasons = seasons,
cast = emptyList()
)
private fun SeriesEntity.toDomain(seasons: List<Season>): Series {
val (realSeasons, sentinelSeason) = seasons.partition { it.id != UNCATEGORIZED_SEASON_ID }
val uncategorizedEpisodes = sentinelSeason.flatMap { it.episodes }
return Series(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
imageUrlPrefix = imageUrlPrefix,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
seasons = realSeasons,
uncategorizedEpisodes = uncategorizedEpisodes,
cast = emptyList(),
)
}
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
id = id,

View File

@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.builtInKotlin=false
android.newDsl=false
purefinReleaseVersionCode=1000033
purefinReleaseVersionCode=1000037
purefinDebugVersionCode=1000003
purefinTvVersionCode=2000017
purefinTvVersionCode=2000023

View File

@@ -1,35 +1,34 @@
[versions]
agp = "9.2.1"
coreKtx = "1.15.0"
agp = "9.3.0"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
kotlin = "2.2.10"
composeBom = "2025.02.00"
jellyfin-core = "1.8.8"
hilt = "2.57.2"
hiltNavigationCompose = "1.2.0"
ksp = "2.3.2"
datastore = "1.1.1"
kotlinxSerializationJson = "1.7.3"
kotlinxCoroutines = "1.9.0"
okhttp = "4.12.0"
foundation = "1.10.1"
tvMaterial = "1.0.1"
coil = "3.3.0"
media3 = "1.9.0"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.10"
composeBom = "2026.06.01"
jellyfin-core = "1.8.11"
hilt = "2.60.1"
hiltNavigationCompose = "1.4.0"
ksp = "2.3.10"
datastore = "1.2.1"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
okhttp = "5.4.0"
foundation = "1.11.4"
tvMaterial = "1.1.0"
coil = "3.5.0"
media3 = "1.10.1"
media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0"
room = "2.7.0"
slf4j = "2.0.17"
nav3Core = "1.1.4"
room = "2.8.4"
slf4j = "2.0.18"
timber = "5.0.1"
firebaseCrashlytics = "20.0.5"
googleGmsGoogleServices = "4.4.4"
firebaseCrashlytics = "20.1.0"
googleGmsGoogleServices = "4.5.0"
googleFirebaseCrashlytics = "3.0.7"
foundationVersion = "1.11.2"
animation = "1.11.2"
animation = "1.11.4"
[libraries]
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" }
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", 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-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"}
@@ -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" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
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" }
[plugins]
android-application = { id = "com.android.application", 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
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
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