mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
56 Commits
53a7d4ad23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 660b50d7cd | |||
| 7472b35d66 | |||
| d8de27c972 | |||
| e33a92271a | |||
| 869bb27497 | |||
| a4bb57822a | |||
| 2d649cdff9 | |||
| 33c83bd30f | |||
| 37bf64f5f8 | |||
| e6da94970a | |||
| 28b647e209 | |||
| b6ebe3d427 | |||
| 76d75221cd | |||
| 6b9724c640 | |||
| c40df41603 | |||
| 0feb16371e | |||
| ced9ca7b9d | |||
| 79066b2ce5 | |||
| dfab094782 | |||
| 26d5e7fdcb | |||
| 5720d4e45b | |||
| f4d2fdd3f7 | |||
| a2121afc2d | |||
| 4879eae9bb | |||
| b28637be4f | |||
| b2db0c2553 | |||
| 7f68b9d32b | |||
| cd3fa8ace1 | |||
| bb61db17bd | |||
| 3dc757f0f7 | |||
| f73ca4e242 | |||
| f25f0e0827 | |||
| 8d194f2baf | |||
| c836c809fc | |||
| b6fc10df57 | |||
| 18e68c6051 | |||
| e8224849cc | |||
| c2cc4e9e65 | |||
| 13bc9410b4 | |||
| 422642ad19 | |||
| 59315b701d | |||
| 3c46650a63 | |||
| f0a7828613 | |||
| 8c758e8262 | |||
| 9872f401f8 | |||
| 86f44ac9ce | |||
| d80b283ded | |||
| 1ac649e8ae | |||
| b23db4bab3 | |||
| 67a141e7d7 | |||
| 9f432edd27 | |||
| 80b1bd8547 | |||
| 00c5689195 | |||
| bd2c1777a5 | |||
| a5d04998ac | |||
| da89a8a661 |
@@ -13,7 +13,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.tv"
|
namespace = "hu.bbara.purefin.tv"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "hu.bbara.purefin.tv"
|
applicationId = "hu.bbara.purefin.tv"
|
||||||
@@ -59,7 +59,7 @@ dependencies {
|
|||||||
implementation(project(":core-ui"))
|
implementation(project(":core-ui"))
|
||||||
implementation(libs.androidx.compose.animation)
|
implementation(libs.androidx.compose.animation)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.foundation)
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home
|
package hu.bbara.purefin.ui.screen.home
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -10,11 +10,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.core.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -30,11 +31,13 @@ fun TvHomeScreen(
|
|||||||
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
color = scheme.background
|
||||||
.background(scheme.background)
|
|
||||||
) {
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TvHomeHeroBackdrop(
|
||||||
|
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
@@ -57,3 +60,4 @@ fun TvHomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package hu.bbara.purefin.ui.screen.home.components
|
|||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -16,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
@@ -31,40 +30,34 @@ internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
|||||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||||
|
|
||||||
private const val TvHomeHeroAnimationMillis = 180
|
private const val TvHomeHeroAnimationMillis = 180
|
||||||
// Half the screen for the billboard, half for the content rows. Tuned so both
|
// The billboard backdrop covers the top portion of the home screen so the hero
|
||||||
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV.
|
// text sits at the bottom of the backdrop and the content rows begin below it.
|
||||||
internal const val TvHomeHeroHeightFraction = 0.5f
|
internal const val TvHomeHeroHeightFraction = 0.45f
|
||||||
|
internal const val TvHomeBackdropOffset = 0.20f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home screen background image plus the darkening gradients that keep the
|
||||||
|
* hero text readable. Drawn behind the home content and sized to the top
|
||||||
|
* [TvHomeHeroHeightFraction] of the screen.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvFocusedItemHero(
|
internal fun TvHomeHeroBackdrop(
|
||||||
item: MediaUiModel,
|
backdropImageUrl: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
Box(
|
Crossfade(
|
||||||
|
targetState = backdropImageUrl,
|
||||||
|
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||||
|
label = "tv-home-hero-backdrop",
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(heightFraction)
|
.fillMaxHeight(heightFraction)
|
||||||
.background(scheme.background)
|
.drawWithContent {
|
||||||
) {
|
drawContent()
|
||||||
Crossfade(
|
|
||||||
targetState = item.backdropImageUrl,
|
|
||||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
|
||||||
label = "tv-home-hero-background"
|
|
||||||
) { imageUrl ->
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Darken the left side so the text block stays readable over any backdrop.
|
// Darken the left side so the text block stays readable over any backdrop.
|
||||||
Box(
|
drawRect(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background.copy(alpha = 0.86f),
|
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||||
@@ -74,12 +67,8 @@ internal fun TvFocusedItemHero(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
// Strong bottom fade so the title and metadata are legible.
|
// Strong bottom fade so the title and metadata are legible.
|
||||||
Box(
|
drawRect(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background.copy(alpha = 0f),
|
0.0f to scheme.background.copy(alpha = 0f),
|
||||||
@@ -89,11 +78,31 @@ internal fun TvFocusedItemHero(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
) { imageUrl ->
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TvFocusedItemHero(
|
||||||
|
item: MediaUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = item,
|
targetState = item,
|
||||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||||
label = "tv-home-hero-content"
|
label = "tv-home-hero-content",
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(heightFraction)
|
||||||
) { hero ->
|
) { hero ->
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.Bottom,
|
||||||
@@ -130,7 +139,6 @@ internal fun TvFocusedItemHero(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
package hu.bbara.purefin.ui.screen.home.components
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -42,7 +40,6 @@ fun TvHomeContent(
|
|||||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||||
val hasNextUp = nextUp.isNotEmpty()
|
val hasNextUp = nextUp.isNotEmpty()
|
||||||
@@ -65,7 +62,6 @@ fun TvHomeContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
|
||||||
.focusRequester(initialFocusRequester),
|
.focusRequester(initialFocusRequester),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
contentPadding = contentPadding
|
contentPadding = contentPadding
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen.player
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -23,10 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
import androidx.compose.material.icons.outlined.PlayArrow
|
|
||||||
import androidx.compose.material.icons.outlined.SkipNext
|
import androidx.compose.material.icons.outlined.SkipNext
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -46,10 +45,11 @@ import androidx.compose.ui.input.key.Key
|
|||||||
import androidx.compose.ui.input.key.KeyEvent
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -75,9 +75,12 @@ import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
|
||||||
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
||||||
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
||||||
|
|
||||||
@@ -161,6 +164,9 @@ fun TvPlayerScreen(
|
|||||||
rootFocusRequester.requestFocus()
|
rootFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||||
|
hideControlsWithTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
fun expandPlaylist() {
|
fun expandPlaylist() {
|
||||||
// TODO: focus management for playlist expansion
|
// TODO: focus management for playlist expansion
|
||||||
@@ -176,6 +182,14 @@ fun TvPlayerScreen(
|
|||||||
trackPanelType = null
|
trackPanelType = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun handleBack() {
|
||||||
|
when {
|
||||||
|
trackPanelType != null -> closeTrackPanel()
|
||||||
|
isPlaylistExpanded -> closePlaylist()
|
||||||
|
controlsVisible -> hideControls()
|
||||||
|
else -> onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val showSkipIntroButton = !controlsVisible
|
val showSkipIntroButton = !controlsVisible
|
||||||
@@ -223,42 +237,28 @@ fun TvPlayerScreen(
|
|||||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
|
||||||
when {
|
|
||||||
trackPanelType != null -> closeTrackPanel()
|
|
||||||
isPlaylistExpanded -> {
|
|
||||||
closePlaylist()
|
|
||||||
}
|
|
||||||
controlsVisible -> {
|
|
||||||
hideControls()
|
|
||||||
}
|
|
||||||
else -> onBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(rootFocusRequester)
|
.focusRequester(rootFocusRequester)
|
||||||
.onPreviewKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
|
if (event.key == Key.Back) {
|
||||||
|
if (event.type == KeyEventType.KeyDown) {
|
||||||
|
handleBack()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
val handled = handleTvPlayerRootKeyEvent(
|
val handled = handleTvPlayerRootKeyEvent(
|
||||||
event = event,
|
event = event,
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||||
onShowControls = ::showControls,
|
onShowControls = ::showControls,
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
|
||||||
trackPanelType = trackPanelType,
|
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
|
||||||
onCollapsePlaylist = {},
|
|
||||||
onHideControls = ::hideControls,
|
|
||||||
onTogglePlayback = {
|
onTogglePlayback = {
|
||||||
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden resume/stop feedback.
|
|
||||||
resumeStopFeedbackCounter++
|
resumeStopFeedbackCounter++
|
||||||
viewModel.togglePlayPause()
|
viewModel.togglePlayPause()
|
||||||
},
|
},
|
||||||
onSeekRelative = {
|
onSeekRelative = {
|
||||||
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden seek timeline.
|
|
||||||
hiddenSeekCounter++
|
hiddenSeekCounter++
|
||||||
viewModel.seekBy(it)
|
viewModel.seekBy(it)
|
||||||
},
|
},
|
||||||
@@ -268,6 +268,7 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
handled
|
handled
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
) {
|
) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
@@ -277,6 +278,8 @@ fun TvPlayerScreen(
|
|||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
player = viewModel.player
|
player = viewModel.player
|
||||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
|
isFocusable = false
|
||||||
|
isFocusableInTouchMode = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = {
|
update = {
|
||||||
@@ -308,9 +311,6 @@ fun TvPlayerScreen(
|
|||||||
onSeekRelative = { deltaMs ->
|
onSeekRelative = { deltaMs ->
|
||||||
viewModel.seekBy(deltaMs)
|
viewModel.seekBy(deltaMs)
|
||||||
},
|
},
|
||||||
onSeekLiveEdge = {
|
|
||||||
viewModel.seekToLiveEdge()
|
|
||||||
},
|
|
||||||
onSkipSegment = {
|
onSkipSegment = {
|
||||||
viewModel.skipActiveSegment()
|
viewModel.skipActiveSegment()
|
||||||
},
|
},
|
||||||
@@ -330,6 +330,17 @@ fun TvPlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = controlsVisible,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
TvPlayerClock()
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSkipIntroButton,
|
visible = showSkipIntroButton,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -367,6 +378,7 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!showSkipIntroButton && !controlsVisible) {
|
||||||
ValueChangeTimedVisibility(
|
ValueChangeTimedVisibility(
|
||||||
value = hiddenSeekCounter,
|
value = hiddenSeekCounter,
|
||||||
hideAfterMillis = 2500L,
|
hideAfterMillis = 2500L,
|
||||||
@@ -382,6 +394,7 @@ fun TvPlayerScreen(
|
|||||||
adMarkers = uiState.ads
|
adMarkers = uiState.ads
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TvPlayerLoadingErrorEndCard(
|
TvPlayerLoadingErrorEndCard(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
@@ -400,7 +413,7 @@ fun TvPlayerScreen(
|
|||||||
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
) {
|
) {
|
||||||
TvPlayerResumeStopFeedback(resume = uiState.isPlaying)
|
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -464,42 +477,35 @@ private fun HiddenTvSeekTimeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TvPlayerClock(modifier: Modifier = Modifier) {
|
||||||
|
var currentTime by remember { mutableStateOf(LocalTime.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
currentTime = LocalTime.now()
|
||||||
|
delay(1_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
|
||||||
|
Text(
|
||||||
|
text = formatter.format(currentTime),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun handleTvPlayerRootKeyEvent(
|
internal fun handleTvPlayerRootKeyEvent(
|
||||||
event: KeyEvent,
|
event: KeyEvent,
|
||||||
controlsVisible: Boolean,
|
controlsVisible: Boolean,
|
||||||
popupVisible: Boolean,
|
popupVisible: Boolean,
|
||||||
isPlaylistExpanded: Boolean,
|
|
||||||
trackPanelType: TvTrackPanelType?,
|
|
||||||
onCloseTrackPanel: () -> Unit,
|
|
||||||
onCollapsePlaylist: () -> Unit,
|
|
||||||
onHideControls: () -> Unit,
|
|
||||||
onTogglePlayback: () -> Unit,
|
onTogglePlayback: () -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onShowControls: () -> Unit,
|
onShowControls: () -> Unit,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (event.type != KeyEventType.KeyDown) return false
|
if (event.type != KeyEventType.KeyDown) return false
|
||||||
|
|
||||||
if (event.key == Key.Back || event.key == Key.Escape) {
|
|
||||||
return when {
|
|
||||||
trackPanelType != null -> {
|
|
||||||
onCloseTrackPanel()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaylistExpanded -> {
|
|
||||||
onCollapsePlaylist()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
controlsVisible -> {
|
|
||||||
onHideControls()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controlsVisible) {
|
if (!controlsVisible) {
|
||||||
return when (event.key) {
|
return when (event.key) {
|
||||||
Key.DirectionLeft -> {
|
Key.DirectionLeft -> {
|
||||||
@@ -535,10 +541,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvPlayerResumeStopFeedback(
|
internal fun TvPlayerStopFeedback(
|
||||||
resume: Boolean,
|
stopped: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
if (!stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.testTag(TvPlayerHiddenStopFeedbackTag)
|
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||||
@@ -548,7 +557,7 @@ internal fun TvPlayerResumeStopFeedback(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (resume) Icons.Outlined.PlayArrow else Icons.Outlined.Pause,
|
imageVector = Icons.Outlined.Pause,
|
||||||
contentDescription = "Play/Pause playback",
|
contentDescription = "Play/Pause playback",
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.size(72.dp)
|
modifier = Modifier.size(72.dp)
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Forward30
|
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
import androidx.compose.material.icons.outlined.PlayArrow
|
import androidx.compose.material.icons.outlined.PlayArrow
|
||||||
import androidx.compose.material.icons.outlined.Replay10
|
|
||||||
import androidx.compose.material.icons.outlined.SkipNext
|
import androidx.compose.material.icons.outlined.SkipNext
|
||||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -63,7 +61,6 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
|
||||||
onSkipSegment: () -> Unit,
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
@@ -109,7 +106,6 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onPlayPause = onPlayPause,
|
onPlayPause = onPlayPause,
|
||||||
onSeek = onSeek,
|
onSeek = onSeek,
|
||||||
onSeekRelative = onSeekRelative,
|
onSeekRelative = onSeekRelative,
|
||||||
onSeekLiveEdge = onSeekLiveEdge,
|
|
||||||
onSkipSegment = onSkipSegment,
|
onSkipSegment = onSkipSegment,
|
||||||
onNext = onNext,
|
onNext = onNext,
|
||||||
onPrevious = onPrevious,
|
onPrevious = onPrevious,
|
||||||
@@ -172,7 +168,6 @@ private fun TvPlayerBottomSection(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
|
||||||
onSkipSegment: () -> Unit,
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
@@ -247,13 +242,6 @@ private fun TvPlayerBottomSection(
|
|||||||
size = 56,
|
size = 56,
|
||||||
modifier = expandPlaylistModifier
|
modifier = expandPlaylistModifier
|
||||||
)
|
)
|
||||||
TvIconButton(
|
|
||||||
icon = Icons.Outlined.Replay10,
|
|
||||||
contentDescription = "Seek backward 10 seconds",
|
|
||||||
onClick = { onSeekRelative(-10_000) },
|
|
||||||
size = 56,
|
|
||||||
modifier = expandPlaylistModifier
|
|
||||||
)
|
|
||||||
TvIconButton(
|
TvIconButton(
|
||||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||||
@@ -263,13 +251,6 @@ private fun TvPlayerBottomSection(
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.testTag(TvPlayerPlayPauseButtonTag)
|
.testTag(TvPlayerPlayPauseButtonTag)
|
||||||
)
|
)
|
||||||
TvIconButton(
|
|
||||||
icon = Icons.Outlined.Forward30,
|
|
||||||
contentDescription = "Seek forward 30 seconds",
|
|
||||||
onClick = { onSeekRelative(30_000) },
|
|
||||||
size = 56,
|
|
||||||
modifier = expandPlaylistModifier
|
|
||||||
)
|
|
||||||
TvIconButton(
|
TvIconButton(
|
||||||
icon = Icons.Outlined.SkipNext,
|
icon = Icons.Outlined.SkipNext,
|
||||||
contentDescription = "Next",
|
contentDescription = "Next",
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ internal fun TvSeriesScreenContent(
|
|||||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||||
mutableStateOf(defaultSeason?.id)
|
mutableStateOf(defaultSeason?.id)
|
||||||
}
|
}
|
||||||
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||||
val initialFocusSeasonId = defaultSeason?.id
|
val initialFocusSeasonId = defaultSeason?.id
|
||||||
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
||||||
series.seasons.firstOrNull { it.id == seasonId }
|
series.allSeasons.firstOrNull { it.id == seasonId }
|
||||||
} ?: defaultSeason
|
} ?: defaultSeason
|
||||||
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
||||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||||
@@ -120,7 +120,7 @@ internal fun TvSeriesScreenContent(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
if (selectedSeason != null) {
|
if (selectedSeason != null) {
|
||||||
TvSeasonTabs(
|
TvSeasonTabs(
|
||||||
seasons = series.seasons,
|
seasons = series.allSeasons,
|
||||||
selectedSeason = selectedSeason,
|
selectedSeason = selectedSeason,
|
||||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
@@ -156,10 +156,10 @@ internal fun TvSeriesScreenContent(
|
|||||||
|
|
||||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||||
if (focusedSeasonId != null) {
|
if (focusedSeasonId != null) {
|
||||||
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||||
}
|
}
|
||||||
|
|
||||||
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull()
|
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Season.nextUpEpisode(): Episode? {
|
private fun Season.nextUpEpisode(): Episode? {
|
||||||
|
|||||||
@@ -153,14 +153,20 @@ private fun TvSeasonTab(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val color = if (isSelected) scheme.primary else scheme.onSurface
|
val color = if (isSelected) scheme.primary else scheme.onSurface
|
||||||
val indicatorColor = if (isSelected) scheme.primary else Color.Transparent
|
val isFocused = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.onFocusChanged { state ->
|
.onFocusChanged { state ->
|
||||||
if (state.isFocused) onFocused()
|
if (state.isFocused) {
|
||||||
|
onFocused()
|
||||||
|
isFocused.value = true
|
||||||
|
} else {
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
|
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
|
||||||
) {
|
) {
|
||||||
TvText(
|
TvText(
|
||||||
text = name,
|
text = name,
|
||||||
@@ -169,13 +175,8 @@ private fun TvSeasonTab(
|
|||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
.height(2.dp)
|
|
||||||
.background(indicatorColor)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,11 +257,9 @@ internal fun TvEpisodeCarousel(
|
|||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(
|
.then(upFocusRequester?.let { requester ->
|
||||||
upFocusRequester?.let { requester ->
|
|
||||||
Modifier.focusProperties { up = requester }
|
Modifier.focusProperties { up = requester }
|
||||||
} ?: Modifier
|
} ?: Modifier)
|
||||||
)
|
|
||||||
.then(
|
.then(
|
||||||
if (episode.id == targetEpisodeId) {
|
if (episode.id == targetEpisodeId) {
|
||||||
Modifier.testTag(SeriesNextUpEpisodeCardTag)
|
Modifier.testTag(SeriesNextUpEpisodeCardTag)
|
||||||
@@ -432,3 +431,15 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
|||||||
// roleSize = 10.sp
|
// roleSize = 10.sp
|
||||||
// )
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
|
||||||
|
if (isFocused) {
|
||||||
|
this.border(
|
||||||
|
width = 2.dp,
|
||||||
|
color = borderColor,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin"
|
namespace = "hu.bbara.purefin"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "hu.bbara.purefin"
|
applicationId = "hu.bbara.purefin"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -37,6 +38,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
@@ -65,8 +68,9 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
|
||||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
|
||||||
|
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
|
||||||
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@@ -88,7 +92,11 @@ fun PlayerScreen(
|
|||||||
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
|
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
|
||||||
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
|
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
|
||||||
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
|
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
|
||||||
|
var isAutoBrightness by remember { mutableStateOf(false) }
|
||||||
|
var dragOvershoot by remember { mutableFloatStateOf(0f) }
|
||||||
|
var autoDragTick by remember { mutableStateOf(0) }
|
||||||
|
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
|
||||||
var showQueuePanel by remember { mutableStateOf(false) }
|
var showQueuePanel by remember { mutableStateOf(false) }
|
||||||
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
||||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||||
@@ -129,6 +137,61 @@ fun PlayerScreen(
|
|||||||
toggleControlsVisibility()
|
toggleControlsVisibility()
|
||||||
hideControlsWithTimeout()
|
hideControlsWithTimeout()
|
||||||
}
|
}
|
||||||
|
fun onBrightnessDragStart(isLeftSide: Boolean) {
|
||||||
|
if (isLeftSide) {
|
||||||
|
dragOvershoot = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onBrightnessDragEnd() {
|
||||||
|
dragOvershoot = 0f
|
||||||
|
}
|
||||||
|
fun onBrightnessDrag(delta: Float) {
|
||||||
|
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
|
||||||
|
if (isAutoBrightness) {
|
||||||
|
if (diff > 0f) {
|
||||||
|
dragOvershoot += diff
|
||||||
|
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
|
||||||
|
isAutoBrightness = false
|
||||||
|
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
|
||||||
|
dragOvershoot = 0f
|
||||||
|
applyBrightness(activity, brightness, isAuto = false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoDragTick++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val newBrightness = brightness + diff
|
||||||
|
if (newBrightness <= 0f) {
|
||||||
|
brightness = 0f
|
||||||
|
applyBrightness(activity, brightness, isAuto = false)
|
||||||
|
dragOvershoot += newBrightness
|
||||||
|
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
|
||||||
|
isAutoBrightness = true
|
||||||
|
dragOvershoot = 0f
|
||||||
|
applyBrightness(activity, brightness, isAuto = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
brightness = newBrightness.coerceIn(0f, 1f)
|
||||||
|
dragOvershoot = 0f
|
||||||
|
applyBrightness(activity, brightness, isAuto = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onVolumeDrag(delta: Float) {
|
||||||
|
val diff = -delta / 800f
|
||||||
|
volume = (volume + diff).coerceIn(0f, 1f)
|
||||||
|
audioManager.setStreamVolume(
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
(volume * maxVolume).roundToInt(),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||||
|
hideControlsWithTimeout()
|
||||||
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
|
restoreBrightness(activity, originalScreenBrightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -145,6 +208,10 @@ fun PlayerScreen(
|
|||||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
update = {
|
||||||
|
it.player = viewModel.player
|
||||||
|
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
|
},
|
||||||
onRelease = { playerView ->
|
onRelease = { playerView ->
|
||||||
playerView.player = null
|
playerView.player = null
|
||||||
},
|
},
|
||||||
@@ -160,20 +227,10 @@ fun PlayerScreen(
|
|||||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||||
onVerticalDragLeft = { delta ->
|
onVerticalDragStart = ::onBrightnessDragStart,
|
||||||
val diff = (-delta / 800f)
|
onVerticalDragEnd = ::onBrightnessDragEnd,
|
||||||
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
|
onVerticalDragLeft = ::onBrightnessDrag,
|
||||||
applyBrightness(activity, brightness)
|
onVerticalDragRight = ::onVolumeDrag,
|
||||||
},
|
|
||||||
onVerticalDragRight = { delta ->
|
|
||||||
val diff = (-delta / 800f)
|
|
||||||
volume = (volume + diff).coerceIn(0f, 1f)
|
|
||||||
audioManager.setStreamVolume(
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
(volume * maxVolume).roundToInt(),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
|
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
|
||||||
horizontalSeekFeedback = deltaMs
|
horizontalSeekFeedback = deltaMs
|
||||||
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
|
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
|
||||||
@@ -221,23 +278,21 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ValueChangeTimedVisibility(
|
ValueChangeTimedVisibility(
|
||||||
value = brightness,
|
value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
|
||||||
hideAfterMillis = 800,
|
hideAfterMillis = 800,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.padding(start = 20.dp)
|
.padding(start = 20.dp)
|
||||||
) { currentBrightness ->
|
) { state ->
|
||||||
PlayerAdjustmentIndicator(
|
PlayerAdjustmentIndicator(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
icon = Icons.Outlined.BrightnessMedium,
|
icon = Icons.Outlined.BrightnessMedium,
|
||||||
contentDescription = "Brightness",
|
contentDescription = "Brightness",
|
||||||
value = currentBrightness,
|
value = state.brightness,
|
||||||
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
|
bottomText = if (state.isAuto) {
|
||||||
"Auto"
|
"Auto"
|
||||||
} else if (currentBrightness >= 0f) {
|
|
||||||
"${(currentBrightness * 100).roundToInt()}%"
|
|
||||||
} else {
|
} else {
|
||||||
null
|
"${(state.brightness * 100).roundToInt()}%"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -425,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readCurrentBrightness(activity: Activity?): Float {
|
private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
|
||||||
val current = activity?.window?.attributes?.screenBrightness
|
|
||||||
return if (current != null && current >= 0) current else -1f
|
private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
|
||||||
|
val windowBrightness = activity?.window?.attributes?.screenBrightness
|
||||||
|
if (windowBrightness != null && windowBrightness >= 0f) {
|
||||||
|
return windowBrightness
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val systemBrightness = Settings.System.getInt(
|
||||||
|
context.contentResolver,
|
||||||
|
Settings.System.SCREEN_BRIGHTNESS
|
||||||
|
)
|
||||||
|
(systemBrightness / 255f).coerceIn(0f, 1f)
|
||||||
|
} catch (e: Settings.SettingNotFoundException) {
|
||||||
|
0.5f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyBrightness(activity: Activity?, value: Float) {
|
private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
|
||||||
activity ?: return
|
activity ?: return
|
||||||
val params = activity.window.attributes
|
val params = activity.window.attributes
|
||||||
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
|
params.screenBrightness = if (isAuto) -1f else brightness
|
||||||
|
activity.window.attributes = params
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
|
||||||
|
activity ?: return
|
||||||
|
val params = activity.window.attributes
|
||||||
|
params.screenBrightness = originalValue
|
||||||
activity.window.attributes = params
|
activity.window.attributes = params
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ fun PlayerGesturesLayer(
|
|||||||
onDoubleTapLeft: () -> Unit,
|
onDoubleTapLeft: () -> Unit,
|
||||||
onVerticalDragLeft: (delta: Float) -> Unit,
|
onVerticalDragLeft: (delta: Float) -> Unit,
|
||||||
onVerticalDragRight: (delta: Float) -> Unit,
|
onVerticalDragRight: (delta: Float) -> Unit,
|
||||||
|
onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
|
||||||
|
onVerticalDragEnd: () -> Unit = {},
|
||||||
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
|
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
|
||||||
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
|
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
|
||||||
currentPositionProvider: () -> Long,
|
currentPositionProvider: () -> Long,
|
||||||
@@ -79,6 +81,7 @@ fun PlayerGesturesLayer(
|
|||||||
var isHorizontalDragActive = false
|
var isHorizontalDragActive = false
|
||||||
var lastPreviewDelta: Long? = null
|
var lastPreviewDelta: Long? = null
|
||||||
var startPositionMs = 0L
|
var startPositionMs = 0L
|
||||||
|
var verticalDragStarted = false
|
||||||
|
|
||||||
drag(down.id) { change ->
|
drag(down.id) { change ->
|
||||||
val delta = change.positionChange()
|
val delta = change.positionChange()
|
||||||
@@ -115,6 +118,10 @@ fun PlayerGesturesLayer(
|
|||||||
DragDirection.VERTICAL -> {
|
DragDirection.VERTICAL -> {
|
||||||
change.consume()
|
change.consume()
|
||||||
val isLeftSide = startX < size.width / 2
|
val isLeftSide = startX < size.width / 2
|
||||||
|
if (!verticalDragStarted) {
|
||||||
|
verticalDragStarted = true
|
||||||
|
onVerticalDragStart(isLeftSide)
|
||||||
|
}
|
||||||
if (isLeftSide) {
|
if (isLeftSide) {
|
||||||
onVerticalDragLeft(delta.y)
|
onVerticalDragLeft(delta.y)
|
||||||
} else {
|
} else {
|
||||||
@@ -134,6 +141,10 @@ fun PlayerGesturesLayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onHorizontalDragPreview(null, null)
|
onHorizontalDragPreview(null, null)
|
||||||
|
|
||||||
|
if (verticalDragStarted) {
|
||||||
|
onVerticalDragEnd()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -33,7 +34,16 @@ fun PlayerSeekBar(
|
|||||||
val currentPosition = positionMs.coerceIn(0, safeDuration)
|
val currentPosition = positionMs.coerceIn(0, safeDuration)
|
||||||
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
|
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
|
||||||
var isScrubbing by remember { mutableStateOf(false) }
|
var isScrubbing by remember { mutableStateOf(false) }
|
||||||
val sliderValue = if (isScrubbing) sliderPosition else currentPosition.toFloat()
|
val sliderValue = sliderPosition
|
||||||
|
|
||||||
|
// Keep sliderPosition in sync with the player's position when the user is not
|
||||||
|
// actively scrubbing. While scrubbing, the local drag value is preserved so the
|
||||||
|
// thumb does not snap back to a stale positionMs prop before the seek is committed.
|
||||||
|
LaunchedEffect(positionMs) {
|
||||||
|
if (!isScrubbing) {
|
||||||
|
sliderPosition = positionMs.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -64,6 +74,7 @@ fun PlayerSeekBar(
|
|||||||
},
|
},
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||||
|
sliderPosition = targetPosition.toFloat()
|
||||||
isScrubbing = false
|
isScrubbing = false
|
||||||
onSeek(targetPosition)
|
onSeek(targetPosition)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package hu.bbara.purefin.ui.screen.series
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Download
|
import androidx.compose.material.icons.outlined.Download
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -144,14 +146,15 @@ private fun SeriesScreenInternal(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigationManager = LocalNavigationManager.current
|
val navigationManager = LocalNavigationManager.current
|
||||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||||
|
var showMarkAsWatchedDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun getDefaultSeason(): Season? {
|
fun getDefaultSeason(): Season? {
|
||||||
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
|
return series.allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.allSeasons.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
|
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
|
||||||
val selectedSeason =
|
val selectedSeason =
|
||||||
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||||
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
|
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
|
||||||
?: selectedSeason?.episodes?.firstOrNull()
|
?: selectedSeason?.episodes?.firstOrNull()
|
||||||
val playAction = remember(nextUpEpisode, offline) {
|
val playAction = remember(nextUpEpisode, offline) {
|
||||||
@@ -188,7 +191,13 @@ private fun SeriesScreenInternal(
|
|||||||
nextUpEpisode = nextUpEpisode,
|
nextUpEpisode = nextUpEpisode,
|
||||||
onPlayClick = playAction ?: {},
|
onPlayClick = playAction ?: {},
|
||||||
onDownloadClick = { showDownloadDialog = true },
|
onDownloadClick = { showDownloadDialog = true },
|
||||||
onMarkAsWatched = onMarkAsWatched,
|
onMarkAsWatched = { watched ->
|
||||||
|
if (watched) {
|
||||||
|
showMarkAsWatchedDialog = true
|
||||||
|
} else {
|
||||||
|
onMarkAsWatched(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
bodyColor = scheme.onSurface
|
bodyColor = scheme.onSurface
|
||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -201,7 +210,7 @@ private fun SeriesScreenInternal(
|
|||||||
) { _modifier ->
|
) { _modifier ->
|
||||||
if (selectedSeason != null) {
|
if (selectedSeason != null) {
|
||||||
SeasonTabs(
|
SeasonTabs(
|
||||||
seasons = series.seasons,
|
seasons = series.allSeasons,
|
||||||
selectedSeason = selectedSeason,
|
selectedSeason = selectedSeason,
|
||||||
onSelect = { selectedSeasonId = it.id },
|
onSelect = { selectedSeasonId = it.id },
|
||||||
modifier = _modifier
|
modifier = _modifier
|
||||||
@@ -233,6 +242,16 @@ private fun SeriesScreenInternal(
|
|||||||
onDismiss = { showDownloadDialog = false }
|
onDismiss = { showDownloadDialog = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMarkAsWatchedDialog) {
|
||||||
|
MarkSeriesAsWatchedConfirmationDialog(
|
||||||
|
onConfirm = {
|
||||||
|
showMarkAsWatchedDialog = false
|
||||||
|
onMarkAsWatched(true)
|
||||||
|
},
|
||||||
|
onDismiss = { showMarkAsWatchedDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.toMediaDetailScaffoldUiModel(
|
private fun Series.toMediaDetailScaffoldUiModel(
|
||||||
@@ -312,3 +331,25 @@ private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = whe
|
|||||||
SeriesDownloadOption.SMART -> true
|
SeriesDownloadOption.SMART -> true
|
||||||
SeriesDownloadOption.DELETE_SMART -> false
|
SeriesDownloadOption.DELETE_SMART -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MarkSeriesAsWatchedConfirmationDialog(
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Mark series as watched?") },
|
||||||
|
text = { Text("This will mark every episode of this series as watched.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text("Mark as watched")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package hu.bbara.purefin.ui.screen.series.components
|
package hu.bbara.purefin.ui.screen.series.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -60,6 +62,7 @@ import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
|||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
import hu.bbara.purefin.ui.model.MediaAction
|
||||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -315,6 +318,7 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeCard(
|
private fun EpisodeCard(
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel = hiltViewModel(),
|
||||||
@@ -322,15 +326,31 @@ private fun EpisodeCard(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
val popupActions = remember(episode.id) {
|
||||||
|
listOf(
|
||||||
|
MediaAction(name = "Mark as watched") {
|
||||||
|
viewModel.markEpisodeAsWatched(episode.id, true)
|
||||||
|
},
|
||||||
|
MediaAction(name = "Mark as unwatched") {
|
||||||
|
viewModel.markEpisodeAsWatched(episode.id, false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
|
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
|
||||||
.clickable { viewModel.onSelectEpisode(
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
viewModel.onSelectEpisode(
|
||||||
seriesId = episode.seriesId,
|
seriesId = episode.seriesId,
|
||||||
seasonId = episode.seasonId,
|
seasonId = episode.seasonId,
|
||||||
episodeId = episode.id
|
episodeId = episode.id
|
||||||
) },
|
)
|
||||||
|
},
|
||||||
|
onLongClick = { showBottomSheet = true }
|
||||||
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -389,6 +409,30 @@ private fun EpisodeCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBottomSheet) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { showBottomSheet = false },
|
||||||
|
modifier = Modifier.testTag(SeriesEpisodeActionsDialogTag),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
popupActions.forEach { action ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(text = action.name) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
action.onClick()
|
||||||
|
showBottomSheet = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
internal const val SeriesPlayButtonTag = "series-play-button"
|
||||||
@@ -402,6 +446,7 @@ internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
|
|||||||
internal const val SeriesSeasonSelectorTag = "series-season-selector"
|
internal const val SeriesSeasonSelectorTag = "series-season-selector"
|
||||||
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
|
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
|
||||||
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
|
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
|
||||||
|
internal const val SeriesEpisodeActionsDialogTag = "series-episode-actions-dialog"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.model"
|
namespace = "hu.bbara.purefin.model"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package hu.bbara.purefin.model
|
|||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
val UNCATEGORIZED_SEASON_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000000")
|
||||||
|
val UNCATEGORIZED_SERIES_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000001")
|
||||||
|
const val UNCATEGORIZED_LABEL: String = "Uncategorized"
|
||||||
|
|
||||||
data class Series(
|
data class Series(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val libraryId: UUID,
|
val libraryId: UUID,
|
||||||
@@ -12,5 +16,27 @@ data class Series(
|
|||||||
val unwatchedEpisodeCount: Int,
|
val unwatchedEpisodeCount: Int,
|
||||||
val seasonCount: Int,
|
val seasonCount: Int,
|
||||||
val seasons: List<Season>,
|
val seasons: List<Season>,
|
||||||
|
val uncategorizedEpisodes: List<Episode> = emptyList(),
|
||||||
val cast: List<CastMember>
|
val cast: List<CastMember>
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real seasons plus a synthetic "Uncategorized" season appended at the end
|
||||||
|
* when [uncategorizedEpisodes] is non-empty. Used by the UI for the season
|
||||||
|
* tab list so uncategorized episodes are selectable like any other season.
|
||||||
|
*/
|
||||||
|
val allSeasons: List<Season>
|
||||||
|
get() = if (uncategorizedEpisodes.isEmpty()) {
|
||||||
|
seasons
|
||||||
|
} else {
|
||||||
|
seasons + Season(
|
||||||
|
id = UNCATEGORIZED_SEASON_ID,
|
||||||
|
seriesId = id,
|
||||||
|
name = UNCATEGORIZED_LABEL,
|
||||||
|
index = 0,
|
||||||
|
unwatchedEpisodeCount = uncategorizedEpisodes.count { !it.watched },
|
||||||
|
episodeCount = uncategorizedEpisodes.size,
|
||||||
|
episodes = uncategorizedEpisodes,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.core.ui"
|
namespace = "hu.bbara.purefin.core.ui"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.core.download"
|
namespace = "hu.bbara.purefin.core.download"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
@@ -39,6 +39,8 @@ dependencies {
|
|||||||
implementation(libs.medi3.exoplayer)
|
implementation(libs.medi3.exoplayer)
|
||||||
implementation(libs.medi3.exoplayer.hls)
|
implementation(libs.medi3.exoplayer.hls)
|
||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
|
implementation(libs.media3.exoplayer.cast)
|
||||||
|
implementation(libs.media3.exoplayer.session)
|
||||||
implementation(libs.datastore)
|
implementation(libs.datastore)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package hu.bbara.purefin.core.concurrency
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesces concurrent and near-time-repeated suspend calls keyed by [key].
|
||||||
|
*
|
||||||
|
* Within a single [run] with a given [key]:
|
||||||
|
* - If a call with the same [key] is already in flight, the new caller
|
||||||
|
* awaits the same [Deferred] and receives the first caller's result.
|
||||||
|
* - If a call with the same [key] completed successfully less than
|
||||||
|
* [TTL_MS] ago, the cached result is returned without re-executing
|
||||||
|
* [block].
|
||||||
|
* - Otherwise [block] is executed, its result is cached for [TTL_MS]
|
||||||
|
* and shared with any concurrent awaiters.
|
||||||
|
*
|
||||||
|
* Failures are never cached: a thrown exception (other than
|
||||||
|
* [CancellationException]) is propagated to all awaiters of the
|
||||||
|
* in-flight call, but a subsequent call with the same key will execute
|
||||||
|
* [block] again immediately.
|
||||||
|
*
|
||||||
|
* [CancellationException] is always re-thrown so coroutine cancellation
|
||||||
|
* semantics are preserved.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SingleFlight @Inject constructor() {
|
||||||
|
private val inFlight: MutableMap<String, Deferred<*>> = ConcurrentHashMap()
|
||||||
|
private val cache: MutableMap<String, CacheEntry<*>> = ConcurrentHashMap()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun <T> run(key: String, block: suspend () -> T): T {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cached = cache[key] as? CacheEntry<T>
|
||||||
|
if (cached != null && now - cached.timestamp < TTL_MS) {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolution: Resolution<T> = mutex.withLock {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cachedNow = cache[key] as? CacheEntry<T>
|
||||||
|
if (cachedNow != null && System.currentTimeMillis() - cachedNow.timestamp < TTL_MS) {
|
||||||
|
return@withLock Resolution.Cached(cachedNow.value)
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val existing = inFlight[key] as? Deferred<T>
|
||||||
|
if (existing != null) {
|
||||||
|
return@withLock Resolution.Join(existing)
|
||||||
|
}
|
||||||
|
val newDeferred = CompletableDeferred<T>()
|
||||||
|
inFlight[key] = newDeferred
|
||||||
|
Resolution.Start(newDeferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (resolution) {
|
||||||
|
is Resolution.Cached -> resolution.value
|
||||||
|
is Resolution.Join -> resolution.deferred.await()
|
||||||
|
is Resolution.Start -> {
|
||||||
|
val deferred = resolution.deferred
|
||||||
|
try {
|
||||||
|
val result = block()
|
||||||
|
cache[key] = CacheEntry(result, System.currentTimeMillis())
|
||||||
|
deferred.complete(result)
|
||||||
|
result
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
deferred.completeExceptionally(error)
|
||||||
|
throw error
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
deferred.completeExceptionally(error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
inFlight.remove(key, deferred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Resolution<T> {
|
||||||
|
data class Cached<T>(val value: T) : Resolution<T>()
|
||||||
|
data class Join<T>(val deferred: Deferred<T>) : Resolution<T>()
|
||||||
|
data class Start<T>(val deferred: CompletableDeferred<T>) : Resolution<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CacheEntry<T>(val value: T, val timestamp: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TTL_MS: Long = 15_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.core.data
|
package hu.bbara.purefin.core.data
|
||||||
|
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.model.Library
|
import hu.bbara.purefin.model.Library
|
||||||
import hu.bbara.purefin.model.Media
|
import hu.bbara.purefin.model.Media
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,15 +13,4 @@ interface HomeRepository {
|
|||||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||||
fun ensureReady()
|
fun ensureReady()
|
||||||
suspend fun refreshHomeData()
|
suspend fun refreshHomeData()
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the full content (movies or series) of a single library on
|
|
||||||
* demand. Used by the library detail screen; the home page no longer
|
|
||||||
* pays for full library content on every refresh.
|
|
||||||
*
|
|
||||||
* Returns `null` when the server returned 304 Not Modified for the
|
|
||||||
* per-library ETag — the caller should keep its previously fetched
|
|
||||||
* copy. An empty list is a legitimate "the library is empty" result.
|
|
||||||
*/
|
|
||||||
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,17 +68,16 @@ class AppViewModel @Inject constructor(
|
|||||||
|
|
||||||
val libraries = homeRepository.libraries.map { libraries ->
|
val libraries = homeRepository.libraries.map { libraries ->
|
||||||
libraries.map {
|
libraries.map {
|
||||||
// The home page no longer fetches the full library content
|
|
||||||
// (see InMemoryAppContentRepository.loadLibraries). isEmpty
|
|
||||||
// therefore reflects the actual server-reported size, not
|
|
||||||
// the contents of the local media repository.
|
|
||||||
LibraryUiModel(
|
LibraryUiModel(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
type = it.type,
|
type = it.type,
|
||||||
posterUrl = it.posterUrl,
|
posterUrl = it.posterUrl,
|
||||||
size = it.size,
|
size = it.size,
|
||||||
isEmpty = it.size == 0,
|
isEmpty = when (it.type) {
|
||||||
|
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
|
||||||
|
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import hu.bbara.purefin.core.data.HomeRepository
|
import hu.bbara.purefin.core.data.HomeRepository
|
||||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
|
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||||
import hu.bbara.purefin.core.navigation.MovieDto
|
import hu.bbara.purefin.core.navigation.MovieDto
|
||||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.core.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||||
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -26,35 +31,24 @@ class LibraryViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||||
|
|
||||||
// Local cache of the last-fetched content per library. Used to preserve
|
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
|
||||||
// content when re-selecting a library whose ETag matches (so the
|
libraryId, libraries ->
|
||||||
// repository's 304 response can be treated as "use the cached copy").
|
if (libraryId == null) {
|
||||||
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
|
return@combine emptyList()
|
||||||
|
}
|
||||||
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
|
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||||
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
|
when (library.type) {
|
||||||
|
LibraryKind.SERIES -> library.series!!.map { series ->
|
||||||
|
SeriesUiModel(series)
|
||||||
|
}
|
||||||
|
LibraryKind.MOVIES -> library.movies!!.map { movie ->
|
||||||
|
MovieUiModel(movie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { homeRepository.ensureReady() }
|
viewModelScope.launch { homeRepository.ensureReady() }
|
||||||
viewModelScope.launch {
|
|
||||||
selectedLibrary.collect { libraryId ->
|
|
||||||
if (libraryId == null) {
|
|
||||||
_contents.value = emptyList()
|
|
||||||
} else {
|
|
||||||
val fresh = homeRepository.loadLibraryContent(libraryId)
|
|
||||||
if (fresh != null) {
|
|
||||||
cachedContents[libraryId] = fresh
|
|
||||||
_contents.value = fresh
|
|
||||||
} else {
|
|
||||||
// ETag 304 — keep the cached copy for this library if
|
|
||||||
// we have one. On a first-visit 304 (rare, would
|
|
||||||
// require the library's ETag to be set without any
|
|
||||||
// prior fetch) the screen briefly shows empty.
|
|
||||||
_contents.value = cachedContents[libraryId] ?: emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMovieSelected(movieId: UUID) {
|
fun onMovieSelected(movieId: UUID) {
|
||||||
|
|||||||
@@ -186,6 +186,12 @@ class SeriesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun markEpisodeAsWatched(episodeId: UUID, watched: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mediaMetadataUpdater.markAsWatched(episodeId, watched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun selectSeries(series: SeriesDto) {
|
fun selectSeries(series: SeriesDto) {
|
||||||
_series.value = series
|
_series.value = series
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package hu.bbara.purefin.core.jellyfin
|
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`.
|
|
||||||
* Distinct from the unqualified client used for image loading and media streaming
|
|
||||||
* because the SDK has its own auth path and the ETag interceptor should not leak
|
|
||||||
* into non-SDK HTTP calls.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class JellyfinSdkClient
|
|
||||||
@@ -43,8 +43,6 @@ class ProgressManager @Inject constructor(
|
|||||||
combine(playbackState, progress, metadata) { state, prog, meta ->
|
combine(playbackState, progress, metadata) { state, prog, meta ->
|
||||||
Triple(state, prog, meta)
|
Triple(state, prog, meta)
|
||||||
}.collect { (state, prog, meta) ->
|
}.collect { (state, prog, meta) ->
|
||||||
lastPositionMs = prog.positionMs
|
|
||||||
lastDurationMs = prog.durationMs
|
|
||||||
isPaused = !state.isPlaying
|
isPaused = !state.isPlaying
|
||||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
val nextPlaybackReportContext = meta.playbackReportContext
|
val nextPlaybackReportContext = meta.playbackReportContext
|
||||||
@@ -53,6 +51,9 @@ class ProgressManager @Inject constructor(
|
|||||||
stopSession()
|
stopSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastPositionMs = prog.positionMs
|
||||||
|
lastDurationMs = prog.durationMs
|
||||||
|
|
||||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
||||||
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
|
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
|
||||||
} else if (activeItemId == mediaId) {
|
} else if (activeItemId == mediaId) {
|
||||||
@@ -65,11 +66,21 @@ class ProgressManager @Inject constructor(
|
|||||||
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
|
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
|
||||||
activeItemId = itemId
|
activeItemId = itemId
|
||||||
activePlaybackReportContext = reportContext
|
activePlaybackReportContext = reportContext
|
||||||
|
val isOffline = reportContext == null
|
||||||
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
||||||
progressJob = scope.launch {
|
progressJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5000)
|
delay(5000)
|
||||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
|
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
|
||||||
|
if (isOffline) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Local cache update failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val progressManager: ProgressManager,
|
private val progressManager: ProgressManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
|
|
||||||
private const val TAG = "PlayerViewModel"
|
private const val TAG = "PlayerViewModel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.data"
|
namespace = "hu.bbara.purefin.data"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package hu.bbara.purefin.data.catalog
|
package hu.bbara.purefin.data.catalog
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import hu.bbara.purefin.core.concurrency.SingleFlight
|
||||||
import hu.bbara.purefin.core.data.HomeRepository
|
import hu.bbara.purefin.core.data.HomeRepository
|
||||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.core.model.MovieUiModel
|
|
||||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
|
||||||
import hu.bbara.purefin.data.converter.toEpisode
|
import hu.bbara.purefin.data.converter.toEpisode
|
||||||
import hu.bbara.purefin.data.converter.toLibrary
|
import hu.bbara.purefin.data.converter.toLibrary
|
||||||
import hu.bbara.purefin.data.converter.toMovie
|
import hu.bbara.purefin.data.converter.toMovie
|
||||||
@@ -37,14 +35,14 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import timber.log.Timber
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InMemoryAppContentRepository @Inject constructor(
|
class InMemoryAppContentRepository @Inject constructor(
|
||||||
@@ -53,6 +51,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private val homeCacheDataStore: DataStore<HomeCache>,
|
private val homeCacheDataStore: DataStore<HomeCache>,
|
||||||
private val onlineMediaRepository: InMemoryLocalMediaRepository,
|
private val onlineMediaRepository: InMemoryLocalMediaRepository,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val singleFlight: SingleFlight,
|
||||||
) : HomeRepository {
|
) : HomeRepository {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var cacheLoadJob: Job? = null
|
private var cacheLoadJob: Job? = null
|
||||||
@@ -76,17 +75,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||||
|
|
||||||
// dateLastMediaAdded values captured at the end of the last successful
|
|
||||||
// refresh. Used to short-circuit per-library /Items/Latest calls when
|
|
||||||
// the timestamp hasn't moved. Persisted via HomeCache.
|
|
||||||
private val cachedLibraryDateLastMediaAdded = mutableMapOf<UUID, String>()
|
|
||||||
|
|
||||||
// dateLastMediaAdded values observed during the CURRENT refresh's
|
|
||||||
// /UserViews response. Read by loadLatestLibraryContent to decide
|
|
||||||
// which libraries need a /Items/Latest call, then merged into
|
|
||||||
// cachedLibraryDateLastMediaAdded and persisted at the end.
|
|
||||||
private var currentLibraryDateLastMediaAdded: Map<UUID, String> = emptyMap()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ensureReady()
|
ensureReady()
|
||||||
}
|
}
|
||||||
@@ -140,14 +128,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadHomeCache() {
|
private suspend fun loadHomeCache() {
|
||||||
Timber.tag(TAG).d("Loading home cache")
|
Timber.tag(TAG).d("Loading home cache")
|
||||||
val cache = homeCacheDataStore.data.first()
|
val cache = homeCacheDataStore.data.first()
|
||||||
// Hydrate the dateLastMediaAdded map from disk before the first
|
|
||||||
// refresh runs, so the per-library short-circuit has a baseline.
|
|
||||||
cachedLibraryDateLastMediaAdded.clear()
|
|
||||||
cache.libraryDateLastMediaAdded.forEach { (key, date) ->
|
|
||||||
runCatching { UUID.fromString(key) }.getOrNull()?.let { uuid ->
|
|
||||||
cachedLibraryDateLastMediaAdded[uuid] = date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cache.libraries.isNotEmpty()) {
|
if (cache.libraries.isNotEmpty()) {
|
||||||
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
||||||
librariesState.value = libraries
|
librariesState.value = libraries
|
||||||
@@ -190,12 +170,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
val movies = onlineMediaRepository.movies.value
|
val movies = onlineMediaRepository.movies.value
|
||||||
val series = onlineMediaRepository.series.value
|
val series = onlineMediaRepository.series.value
|
||||||
val episodes = onlineMediaRepository.episodes.value
|
val episodes = onlineMediaRepository.episodes.value
|
||||||
// Persist this refresh's dateLastMediaAdded as the new baseline
|
|
||||||
// for the next refresh's per-library short-circuit.
|
|
||||||
cachedLibraryDateLastMediaAdded.clear()
|
|
||||||
currentLibraryDateLastMediaAdded.forEach { (id, date) ->
|
|
||||||
cachedLibraryDateLastMediaAdded[id] = date
|
|
||||||
}
|
|
||||||
val cache = HomeCache(
|
val cache = HomeCache(
|
||||||
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
||||||
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
|
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
|
||||||
@@ -204,7 +178,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
uuid.toString() to items.map { it.toCachedItem() }
|
uuid.toString() to items.map { it.toCachedItem() }
|
||||||
}.toMap(),
|
}.toMap(),
|
||||||
libraries = librariesState.value.map { it.toCachedLibrary() },
|
libraries = librariesState.value.map { it.toCachedLibrary() },
|
||||||
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
|
|
||||||
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
||||||
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
||||||
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
||||||
@@ -239,93 +212,60 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadLibraries() {
|
private suspend fun loadLibraries() = singleFlight.run("AppContent:loadLibraries") {
|
||||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load libraries")
|
handleRefreshFailure(error, "Unable to load libraries")
|
||||||
}
|
}
|
||||||
|
val filteredLibraries = librariesItem.filter {
|
||||||
|
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||||
|
}
|
||||||
|
val emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) }
|
||||||
|
librariesState.value = emptyLibraries
|
||||||
|
|
||||||
// Build the new library list in a single pass, so collectors of
|
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
|
||||||
// `librariesState` only ever see the final value. When /UserViews
|
|
||||||
// returns 304 we already have the BaseItemDto list in memory, so
|
|
||||||
// we just rebuild from it. Otherwise we have the new DTOs.
|
|
||||||
val filledLibraries: List<Library> = if (librariesItem == null) {
|
|
||||||
// ETag 304 on /UserViews — re-fetch the count per library
|
|
||||||
// against the existing in-memory state. The size fallback
|
|
||||||
// (`?: library.size`) handles a 304 on /Items too, so a
|
|
||||||
// 304 on both endpoints leaves Library.size untouched.
|
|
||||||
librariesState.value.map { library ->
|
|
||||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
|
||||||
when (library.type) {
|
|
||||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
|
||||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Capture the server-reported "last media added" timestamp
|
|
||||||
// per library for this refresh. loadLatestLibraryContent
|
|
||||||
// reads this map to decide which libraries need a
|
|
||||||
// /Items/Latest call, and persistHomeCache writes it to
|
|
||||||
// HomeCache as the new "cached" baseline.
|
|
||||||
currentLibraryDateLastMediaAdded = librariesItem
|
|
||||||
.mapNotNull { dto ->
|
|
||||||
dto.dateLastMediaAdded?.let { dto.id to it.toString() }
|
|
||||||
}
|
|
||||||
.toMap()
|
|
||||||
|
|
||||||
// Count-only refresh: ask the server for the number of items
|
|
||||||
// in each library instead of enumerating them. The home
|
|
||||||
// dashboard only uses Library.size for the library cards;
|
|
||||||
// the full movies/series lists are fetched on demand by the
|
|
||||||
// library detail screen via loadLibraryContent, and on the
|
|
||||||
// home rows by loadSuggestions / loadContinueWatching /
|
|
||||||
// loadLatestLibraryContent.
|
|
||||||
librariesItem.map { dto ->
|
|
||||||
val library = dto.toLibrary(serverUrl())
|
|
||||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
|
||||||
when (library.type) {
|
|
||||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
|
||||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
librariesState.value = filledLibraries
|
librariesState.value = filledLibraries
|
||||||
|
|
||||||
|
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||||
|
onlineMediaRepository.upsertMovies(movies)
|
||||||
|
|
||||||
|
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
|
||||||
|
onlineMediaRepository.upsertSeries(series)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
|
private suspend fun loadLibrary(library: Library): Library {
|
||||||
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList()
|
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||||
// ETag hit — return null so the caller can keep its previously
|
|
||||||
// fetched copy. The library detail viewmodel uses this signal to
|
|
||||||
// preserve its in-memory cache across re-selections.
|
|
||||||
val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
|
|
||||||
val url = serverUrl()
|
|
||||||
return when (library.type) {
|
|
||||||
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
|
|
||||||
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadSuggestions() {
|
|
||||||
val url = serverUrl()
|
|
||||||
// Head-first fetch: ask the server for just the first 2 items and
|
|
||||||
// compare to the cached head. If the head is unchanged, the rest
|
|
||||||
// of the row is also unchanged (the home screen shows the head
|
|
||||||
// prominently and only rotates when new content arrives at the
|
|
||||||
// top), so we can skip the full request entirely.
|
|
||||||
val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() }
|
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load suggestions head")
|
handleRefreshFailure(error, "Unable to load library ${library.id}")
|
||||||
|
}
|
||||||
|
return when (library.type) {
|
||||||
|
LibraryKind.MOVIES -> library.copy(
|
||||||
|
movies = contentItem.map { it.toMovie(serverUrl()) },
|
||||||
|
size = contentItem.size,
|
||||||
|
)
|
||||||
|
LibraryKind.SERIES -> library.copy(
|
||||||
|
series = contentItem.map { it.toSeries(serverUrl()) },
|
||||||
|
size = contentItem.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (headItems == null) return
|
|
||||||
if (headMatches(headItems, suggestionsState.value)) return
|
|
||||||
|
|
||||||
// Head changed (or first refresh) — fetch the full list.
|
private suspend fun loadSuggestions() = singleFlight.run("AppContent:loadSuggestions") {
|
||||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load suggestions")
|
handleRefreshFailure(error, "Unable to load suggestions")
|
||||||
}
|
}
|
||||||
if (suggestionsItems == null) return
|
// Upsert full episode details BEFORE publishing the new row, so the
|
||||||
|
// home viewmodel's `combine` of this flow with the local media
|
||||||
|
// repository never observes an empty intermediate state. If we
|
||||||
|
// assigned suggestionsState first, the new episode IDs would be
|
||||||
|
// unresolvable for a single emission and the Suggestions section
|
||||||
|
// would briefly drop from the home screen.
|
||||||
|
suggestionsItems.forEach { item ->
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
|
}
|
||||||
|
}
|
||||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||||
@@ -333,32 +273,24 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert full details so the home viewmodel can look up each item.
|
|
||||||
suggestionsItems.forEach { item ->
|
|
||||||
when (item.type) {
|
|
||||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
|
||||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadContinueWatching() {
|
private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") {
|
||||||
val url = serverUrl()
|
|
||||||
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
|
|
||||||
.getOrElse { error ->
|
|
||||||
handleRefreshFailure(error, "Unable to load continue watching head")
|
|
||||||
}
|
|
||||||
if (headItems == null) return
|
|
||||||
if (headMatches(headItems, continueWatchingState.value)) return
|
|
||||||
|
|
||||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load continue watching")
|
handleRefreshFailure(error, "Unable to load continue watching")
|
||||||
}
|
}
|
||||||
if (continueWatchingItems == null) return
|
// Upsert full episode details BEFORE publishing the new row, so the
|
||||||
|
// home viewmodel's `combine` of this flow with the local media
|
||||||
|
// repository never observes an empty intermediate state. If we
|
||||||
|
// assigned continueWatchingState first, the new episode IDs would
|
||||||
|
// be unresolvable for a single emission and the Continue Watching
|
||||||
|
// section would briefly drop from the home screen.
|
||||||
|
continueWatchingItems.forEach { item ->
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
|
}
|
||||||
|
}
|
||||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||||
@@ -366,102 +298,57 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
continueWatchingItems.forEach { item ->
|
|
||||||
when (item.type) {
|
|
||||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
|
||||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadNextUp() {
|
private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") {
|
||||||
val url = serverUrl()
|
|
||||||
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
|
|
||||||
.getOrElse { error ->
|
|
||||||
handleRefreshFailure(error, "Unable to load next up head")
|
|
||||||
}
|
|
||||||
if (headItems == null) return
|
|
||||||
if (headMatches(headItems, nextUpState.value)) return
|
|
||||||
|
|
||||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load next up")
|
handleRefreshFailure(error, "Unable to load next up")
|
||||||
}
|
}
|
||||||
if (nextUpItems == null) return
|
// Upsert full episode details BEFORE publishing the new row, so the
|
||||||
|
// home viewmodel's `combine` of this flow with the local media
|
||||||
|
// repository never observes an empty intermediate state. If we
|
||||||
|
// assigned nextUpState first, the new episode IDs would be
|
||||||
|
// unresolvable for a single emission and the Next Up section
|
||||||
|
// would briefly drop from the home screen.
|
||||||
|
nextUpItems.forEach { item ->
|
||||||
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
|
}
|
||||||
nextUpState.value = nextUpItems.map { item ->
|
nextUpState.value = nextUpItems.map { item ->
|
||||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
nextUpItems.forEach { item ->
|
|
||||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadLatestLibraryContent() {
|
private suspend fun loadLatestLibraryContent() = singleFlight.run("AppContent:loadLatestLibraryContent") {
|
||||||
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
|
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
|
||||||
// the same /Users/{userId}/Views response a second time per refresh.
|
// the same /Users/{userId}/Views response a second time per refresh.
|
||||||
val filteredLibraries = librariesState.value
|
val filteredLibraries = librariesState.value
|
||||||
val url = serverUrl()
|
val url = serverUrl()
|
||||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||||
// Layer 1: skip when the server-reported "last media added"
|
|
||||||
// timestamp is unchanged since the last refresh. This is the
|
|
||||||
// cheapest check (no request) and handles the common "no new
|
|
||||||
// content" case.
|
|
||||||
val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
|
|
||||||
val currentDate = currentLibraryDateLastMediaAdded[library.id]
|
|
||||||
if (cachedDate != null && cachedDate == currentDate) {
|
|
||||||
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
|
|
||||||
} else {
|
|
||||||
// Layer 2: head-only fetch. If the first 2 items of the
|
|
||||||
// latest row are the same as the cached head, the rest of
|
|
||||||
// the row is also unchanged (the home row only rotates at
|
|
||||||
// the top) and we can skip the full request.
|
|
||||||
val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) }
|
|
||||||
.getOrElse { error ->
|
|
||||||
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
|
|
||||||
}
|
|
||||||
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
|
|
||||||
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
|
|
||||||
library.id to cachedSlice
|
|
||||||
} else {
|
|
||||||
// Layer 3: head changed — fetch the full row.
|
|
||||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
||||||
}
|
}
|
||||||
if (latestFromLibrary == null) {
|
library.id to when (library.type) {
|
||||||
library.id to cachedSlice
|
|
||||||
} else {
|
|
||||||
val media = when (library.type) {
|
|
||||||
LibraryKind.MOVIES -> latestFromLibrary.map {
|
LibraryKind.MOVIES -> latestFromLibrary.map {
|
||||||
val movie = it.toMovie(url)
|
val movie = it.toMovie(url)
|
||||||
onlineMediaRepository.upsertMovies(listOf(movie))
|
|
||||||
Media.MovieMedia(movieId = movie.id)
|
Media.MovieMedia(movieId = movie.id)
|
||||||
}
|
}
|
||||||
LibraryKind.SERIES -> latestFromLibrary.map { dto ->
|
LibraryKind.SERIES -> latestFromLibrary.map {
|
||||||
when (dto.type) {
|
when (it.type) {
|
||||||
BaseItemKind.SERIES -> {
|
BaseItemKind.SERIES -> {
|
||||||
val series = dto.toSeries(url)
|
val series = it.toSeries(url)
|
||||||
onlineMediaRepository.upsertSeries(listOf(series))
|
|
||||||
Media.SeriesMedia(seriesId = series.id)
|
Media.SeriesMedia(seriesId = series.id)
|
||||||
}
|
}
|
||||||
BaseItemKind.SEASON -> {
|
BaseItemKind.SEASON -> {
|
||||||
val season = dto.toSeason()
|
val season = it.toSeason()
|
||||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||||
}
|
}
|
||||||
BaseItemKind.EPISODE -> {
|
BaseItemKind.EPISODE -> {
|
||||||
val episode = dto.toEpisode(url)
|
val episode = it.toEpisode(url)
|
||||||
onlineMediaRepository.upsertEpisodes(listOf(episode))
|
|
||||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||||
}
|
}
|
||||||
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}")
|
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
library.id to media
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,29 +356,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
latestLibraryContentState.value = latestLibraryContents
|
latestLibraryContentState.value = latestLibraryContents
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the first [JellyfinApiClient.HEAD_LIMIT] IDs of the
|
|
||||||
* freshly fetched [headDtos] match the first
|
|
||||||
* [JellyfinApiClient.HEAD_LIMIT] IDs of the cached [cachedMedia]. Used
|
|
||||||
* by the home row refreshers to short-circuit the full request when
|
|
||||||
* the head of the row is unchanged.
|
|
||||||
*
|
|
||||||
* Order-sensitive: the comparison is positional. The Jellyfin server
|
|
||||||
* can reorder rows (e.g., Suggestions reorders on a relevance
|
|
||||||
* recompute even when the item set is unchanged), which will report
|
|
||||||
* a false "head changed" and pay for the full request. The reverse
|
|
||||||
* false negative — head order unchanged but a mid-list item rotated —
|
|
||||||
* is a known limitation of the head-diff heuristic.
|
|
||||||
*/
|
|
||||||
private fun headMatches(
|
|
||||||
headDtos: List<BaseItemDto>,
|
|
||||||
cachedMedia: List<Media>,
|
|
||||||
): Boolean {
|
|
||||||
val headIds = headDtos.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
|
||||||
val cachedIds = cachedMedia.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
|
||||||
return headIds == cachedIds
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun serverUrl(): String {
|
private suspend fun serverUrl(): String {
|
||||||
return userSessionRepository.serverUrl.first()
|
return userSessionRepository.serverUrl.first()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package hu.bbara.purefin.data.catalog
|
package hu.bbara.purefin.data.catalog
|
||||||
|
|
||||||
|
import hu.bbara.purefin.core.concurrency.SingleFlight
|
||||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
|
import hu.bbara.purefin.data.converter.isUncategorizedEpisode
|
||||||
import hu.bbara.purefin.data.converter.toEpisode
|
import hu.bbara.purefin.data.converter.toEpisode
|
||||||
import hu.bbara.purefin.data.converter.toMovie
|
import hu.bbara.purefin.data.converter.toMovie
|
||||||
import hu.bbara.purefin.data.converter.toSeason
|
import hu.bbara.purefin.data.converter.toSeason
|
||||||
@@ -10,10 +12,8 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
|||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
|
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -32,10 +32,9 @@ import javax.inject.Singleton
|
|||||||
class InMemoryLocalMediaRepository @Inject constructor(
|
class InMemoryLocalMediaRepository @Inject constructor(
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
private val jellyfinApiClient: JellyfinApiClient,
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val singleFlight: SingleFlight,
|
||||||
) : LocalMediaRepository {
|
) : LocalMediaRepository {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
private val serverUrl = userSessionRepository.serverUrl
|
private val serverUrl = userSessionRepository.serverUrl
|
||||||
|
|
||||||
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
|
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
|
||||||
@@ -56,13 +55,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
|
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
|
||||||
episodesState.map { it[id] }.distinctUntilChanged()
|
episodesState.map { it[id] }.distinctUntilChanged()
|
||||||
|
|
||||||
override suspend fun loadMovie(id: UUID) {
|
override suspend fun loadMovie(id: UUID) = singleFlight.run("LocalMedia:loadMovie:$id") {
|
||||||
if (moviesState.value.containsKey(id)) return
|
|
||||||
try {
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.MOVIE) {
|
if (item.type != BaseItemKind.MOVIE) {
|
||||||
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
||||||
return
|
return@run
|
||||||
}
|
}
|
||||||
val movie = item.toMovie(serverUrl.first())
|
val movie = item.toMovie(serverUrl.first())
|
||||||
moviesState.update { current -> current + (movie.id to movie) }
|
moviesState.update { current -> current + (movie.id to movie) }
|
||||||
@@ -75,16 +73,26 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadSeries(id: UUID) {
|
override suspend fun loadSeries(id: UUID) = singleFlight.run("LocalMedia:loadSeries:$id") {
|
||||||
if (seriesState.value.containsKey(id)) return
|
|
||||||
try {
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.SERIES) {
|
if (item.type != BaseItemKind.SERIES) {
|
||||||
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
||||||
return
|
return@run
|
||||||
}
|
}
|
||||||
val series = item.toSeries(serverUrl.first())
|
val series = item.toSeries(serverUrl.first())
|
||||||
seriesState.update { current -> current + (series.id to series) }
|
seriesState.update { current ->
|
||||||
|
val existing = current[series.id]
|
||||||
|
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) {
|
||||||
|
series.copy(
|
||||||
|
seasons = existing.seasons,
|
||||||
|
uncategorizedEpisodes = existing.uncategorizedEpisodes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
series
|
||||||
|
}
|
||||||
|
current + (series.id to merged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
@@ -94,13 +102,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadEpisode(id: UUID) {
|
override suspend fun loadEpisode(id: UUID) = singleFlight.run("LocalMedia:loadEpisode:$id") {
|
||||||
if (episodesState.value.containsKey(id)) return
|
|
||||||
try {
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.EPISODE) {
|
if (item.type != BaseItemKind.EPISODE) {
|
||||||
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
||||||
return
|
return@run
|
||||||
}
|
}
|
||||||
val episode = item.toEpisode(serverUrl.first())
|
val episode = item.toEpisode(serverUrl.first())
|
||||||
episodesState.update { current -> current + (episode.id to episode) }
|
episodesState.update { current -> current + (episode.id to episode) }
|
||||||
@@ -118,81 +125,116 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun upsertSeries(series: List<Series>) {
|
fun upsertSeries(series: List<Series>) {
|
||||||
seriesState.update { current -> current + series.associateBy { it.id } }
|
seriesState.update { current ->
|
||||||
|
current + series.associateBy { it.id }.mapValues { (id, newSeries) ->
|
||||||
|
val existing = current[id]
|
||||||
|
if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) {
|
||||||
|
newSeries.copy(
|
||||||
|
seasons = existing.seasons,
|
||||||
|
uncategorizedEpisodes = existing.uncategorizedEpisodes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
newSeries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upsertEpisodes(episodes: List<Episode>) {
|
fun upsertEpisodes(episodes: List<Episode>) {
|
||||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadSeasons(seriesId: UUID) {
|
override suspend fun loadSeasons(seriesId: UUID) = singleFlight.run("LocalMedia:loadSeasons:$seriesId") {
|
||||||
try {
|
try {
|
||||||
loadSeasonsInternal(seriesId)
|
// Ensure the series is loaded
|
||||||
|
var series = seriesState.value[seriesId]
|
||||||
|
if (series == null) {
|
||||||
|
loadSeries(seriesId)
|
||||||
|
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingSeasonsById = series.seasons.associateBy { it.id }
|
||||||
|
val updatedSeries = series.copy(
|
||||||
|
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }.map { fresh ->
|
||||||
|
val existing = existingSeasonsById[fresh.id]
|
||||||
|
if (existing != null && existing.episodes.isNotEmpty()) {
|
||||||
|
fresh.copy(episodes = existing.episodes)
|
||||||
|
} else {
|
||||||
|
fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Timber.tag(TAG).e(error, "Failed to load content for series $seriesId")
|
Timber.tag(TAG).e(error, "Failed to load seasons for series $seriesId")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
|
||||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") {
|
||||||
|
try {
|
||||||
|
// The "Uncategorized" season is synthetic; its episodes are
|
||||||
|
// populated as a side effect of loading real seasons, so
|
||||||
|
// selecting that tab must not trigger a Jellyfin API call.
|
||||||
|
if (seasonId == UNCATEGORIZED_SEASON_ID) return@run
|
||||||
|
|
||||||
val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
// Ensure the series and season is loaded
|
||||||
|
var series = seriesState.value[seriesId]
|
||||||
// Await the parallel loadSeries to populate the series entry.
|
if (series == null) {
|
||||||
// Precondition: seriesId refers to a real series (selectSeries always uses a
|
loadSeries(seriesId)
|
||||||
// SeriesDto from a real series list). If loadSeries returns without populating
|
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
|
||||||
// state (null item / wrong type) this suspends until cancelled by structured
|
|
||||||
// concurrency when a sibling load* call fails.
|
|
||||||
seriesState.first { it.containsKey(seriesId) }
|
|
||||||
|
|
||||||
seriesState.update { current ->
|
|
||||||
val existing = current[seriesId] ?: return@update current
|
|
||||||
if (existing.seasons.isNotEmpty()) return@update current
|
|
||||||
current + (seriesId to existing.copy(seasons = seasons))
|
|
||||||
}
|
}
|
||||||
|
// The season we are about to load episodes for must be present
|
||||||
|
// in `series.seasons` for the `seriesState.update { ... }` block
|
||||||
|
// below to attach the fetched episodes to it; otherwise the
|
||||||
|
// `map { ... }` iterates over an empty list and the freshly
|
||||||
|
// fetched episodes are silently dropped, leaving the series
|
||||||
|
// detail screen stuck on "Loading seasons…". If the seasons
|
||||||
|
// have not been hydrated yet (e.g. `loadSeasons` from
|
||||||
|
// `selectSeries` is still racing with us), load them first.
|
||||||
|
if (series.seasons.none { it.id == seasonId }) {
|
||||||
|
loadSeasons(seriesId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
|
||||||
// Fast path: season already cached with episodes or known empty — skip the fetch.
|
|
||||||
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
|
|
||||||
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
|
|
||||||
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
|
|
||||||
// seasonId is always a valid season from a real EpisodeDto in this path.
|
|
||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
val seriesName = seriesState.value[seriesId]?.name
|
||||||
.map { it.toEpisode(serverUrl) }
|
val (categorized, uncategorized) = jellyfinApiClient
|
||||||
|
.getEpisodesInSeason(seriesId, seasonId)
|
||||||
// Await the season to be present in state (immediate when pre-loaded; waits for
|
.partition { !it.isUncategorizedEpisode() }
|
||||||
// the parallel loadSeasons otherwise).
|
val categorizedEpisodes = categorized.map {
|
||||||
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true }
|
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
|
||||||
|
}
|
||||||
// Re-check guard after await: a concurrent call may have filled episodes already.
|
val uncategorizedEpisodes = uncategorized.map {
|
||||||
val series = seriesState.value[seriesId] ?: return
|
it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName)
|
||||||
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
|
|
||||||
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seriesState.update { current ->
|
seriesState.update { current ->
|
||||||
val currentSeries = current[seriesId] ?: return@update current
|
val currentSeries = current[seriesId] ?: return@update current
|
||||||
val updatedSeries = currentSeries.copy(
|
val updatedSeries = currentSeries.copy(
|
||||||
seasons = currentSeries.seasons.map {
|
seasons = currentSeries.seasons.map {
|
||||||
if (it.id == seasonId && it.episodes.isEmpty()) {
|
if (it.id == seasonId && it.episodes.isEmpty()) {
|
||||||
it.copy(episodes = episodes)
|
it.copy(episodes = categorizedEpisodes)
|
||||||
} else {
|
} else {
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
uncategorizedEpisodes = (
|
||||||
|
currentSeries.uncategorizedEpisodes + uncategorizedEpisodes
|
||||||
|
).distinctBy { it.id }
|
||||||
)
|
)
|
||||||
current + (updatedSeries.id to updatedSeries)
|
current + (updatedSeries.id to updatedSeries)
|
||||||
}
|
}
|
||||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
episodesState.update { current ->
|
||||||
|
current + (categorizedEpisodes + uncategorizedEpisodes).associateBy { it.id }
|
||||||
|
}
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).e(error, "Failed to load episodes for season $seasonId")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||||
@@ -269,8 +311,21 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val uncategorizedEpisodes = if (
|
||||||
|
series.uncategorizedEpisodes.none { it.id == updatedEpisode.id }
|
||||||
|
) {
|
||||||
|
series.uncategorizedEpisodes
|
||||||
|
} else {
|
||||||
|
changed = true
|
||||||
|
series.uncategorizedEpisodes.map { episode ->
|
||||||
|
if (episode.id == updatedEpisode.id) updatedEpisode else episode
|
||||||
|
}
|
||||||
|
}
|
||||||
if (changed) {
|
if (changed) {
|
||||||
current + (series.id to series.copy(seasons = seasons))
|
current + (series.id to series.copy(
|
||||||
|
seasons = seasons,
|
||||||
|
uncategorizedEpisodes = uncategorizedEpisodes,
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
current
|
current
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import hu.bbara.purefin.model.LibraryKind
|
|||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
|
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||||
|
import hu.bbara.purefin.model.UNCATEGORIZED_SERIES_ID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun BaseItemDto.toLibrary(serverUrl: String): Library {
|
fun BaseItemDto.toLibrary(serverUrl: String): Library? {
|
||||||
return when (collectionType) {
|
return when (collectionType) {
|
||||||
CollectionType.MOVIES -> Library(
|
CollectionType.MOVIES -> Library(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -42,9 +46,19 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
|
|||||||
size = childCount ?: 0,
|
size = childCount ?: 0,
|
||||||
series = emptyList(),
|
series = emptyList(),
|
||||||
)
|
)
|
||||||
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
|
else -> {
|
||||||
|
// Jellyfin servers can return other top-level views (e.g. boxsets,
|
||||||
|
// music, photos, live TV) that this app does not surface on the
|
||||||
|
// home dashboard. Skip them so a single unsupported view does
|
||||||
|
// not abort the whole refresh and leave the user with an empty
|
||||||
|
// home screen.
|
||||||
|
Timber.tag(TAG).w("Skipping unsupported library type: $collectionType")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "BaseItemDtoConverter"
|
||||||
|
|
||||||
fun BaseItemDto.toMovie(serverUrl: String): Movie {
|
fun BaseItemDto.toMovie(serverUrl: String): Movie {
|
||||||
return Movie(
|
return Movie(
|
||||||
@@ -90,24 +104,38 @@ fun BaseItemDto.toSeason(): Season {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
/**
|
||||||
|
* An episode is considered "uncategorized" when it is missing any of the
|
||||||
|
* identity/season fields required to place it under a real season. Missing
|
||||||
|
* watch data ([userData]) is intentionally NOT a trigger — it is defaulted
|
||||||
|
* so the episode can still appear under its real season.
|
||||||
|
*/
|
||||||
|
fun BaseItemDto.isUncategorizedEpisode(): Boolean =
|
||||||
|
parentId == null || seriesId == null || seriesName == null ||
|
||||||
|
parentIndexNumber == null || indexNumber == null
|
||||||
|
|
||||||
|
fun BaseItemDto.toEpisode(
|
||||||
|
serverUrl: String,
|
||||||
|
fallbackSeriesId: UUID? = null,
|
||||||
|
fallbackSeriesName: String? = null,
|
||||||
|
): Episode {
|
||||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||||
val imageUrlPrefix = id?.let { itemId ->
|
val imageUrlPrefix = id?.let { itemId ->
|
||||||
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
|
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
|
||||||
} ?: ""
|
} ?: ""
|
||||||
return Episode(
|
return Episode(
|
||||||
id = id,
|
id = id ?: error("Episode without id"),
|
||||||
seriesId = seriesId!!,
|
seriesId = seriesId ?: fallbackSeriesId ?: UNCATEGORIZED_SERIES_ID,
|
||||||
seriesName = seriesName!!,
|
seriesName = seriesName ?: fallbackSeriesName ?: "Unknown",
|
||||||
seasonId = parentId!!,
|
seasonId = parentId ?: UNCATEGORIZED_SEASON_ID,
|
||||||
seasonIndex = parentIndexNumber!!,
|
seasonIndex = parentIndexNumber ?: 0,
|
||||||
title = name ?: "Unknown title",
|
title = name ?: "Unknown title",
|
||||||
index = indexNumber!!,
|
index = indexNumber ?: 0,
|
||||||
releaseDate = releaseDate,
|
releaseDate = releaseDate,
|
||||||
rating = officialRating ?: "NR",
|
rating = officialRating ?: "NR",
|
||||||
runtime = formatRuntime(runTimeTicks),
|
runtime = formatRuntime(runTimeTicks),
|
||||||
progress = userData!!.playedPercentage,
|
progress = userData?.playedPercentage,
|
||||||
watched = userData!!.played,
|
watched = userData?.played ?: false,
|
||||||
format = container?.uppercase() ?: "VIDEO",
|
format = container?.uppercase() ?: "VIDEO",
|
||||||
synopsis = overview ?: "No synopsis available.",
|
synopsis = overview ?: "No synopsis available.",
|
||||||
imageUrlPrefix = imageUrlPrefix,
|
imageUrlPrefix = imageUrlPrefix,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
|||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
|
||||||
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
|
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
|
||||||
|
import hu.bbara.purefin.data.jellyfin.playback.SubtitleConfigurationMapper
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.MediaSegment
|
import hu.bbara.purefin.model.MediaSegment
|
||||||
import hu.bbara.purefin.model.PlayableMedia
|
import hu.bbara.purefin.model.PlayableMedia
|
||||||
@@ -50,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
private val mediaDownloadController: MediaDownloadController,
|
private val mediaDownloadController: MediaDownloadController,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val subtitleConfigurationMapper: SubtitleConfigurationMapper,
|
||||||
@param:Offline private val offlineMediaRepository: LocalMediaRepository,
|
@param:Offline private val offlineMediaRepository: LocalMediaRepository,
|
||||||
private val offlineMediaManager: OfflineMediaManager,
|
private val offlineMediaManager: OfflineMediaManager,
|
||||||
) : PlayableMediaRepository {
|
) : PlayableMediaRepository {
|
||||||
@@ -198,6 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
val serverUrl = userSessionRepository.serverUrl.first()
|
val serverUrl = userSessionRepository.serverUrl.first()
|
||||||
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
|
val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY)
|
||||||
|
|
||||||
|
val subtitleConfigurations = subtitleConfigurationMapper.createSubtitleConfigurations(
|
||||||
|
mediaSource = playbackSource.mediaSource,
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
)
|
||||||
|
|
||||||
val mediaItem = createMediaItem(
|
val mediaItem = createMediaItem(
|
||||||
mediaId = mediaId.toString(),
|
mediaId = mediaId.toString(),
|
||||||
url = playbackSource.directPlayUrl,
|
url = playbackSource.directPlayUrl,
|
||||||
@@ -205,6 +212,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
subtitle = seasonEpisodeLabel(baseItem),
|
subtitle = seasonEpisodeLabel(baseItem),
|
||||||
artworkUrl = artworkUrl,
|
artworkUrl = artworkUrl,
|
||||||
playbackTag = playbackSource.toPlaybackMediaItemTag(),
|
playbackTag = playbackSource.toPlaybackMediaItemTag(),
|
||||||
|
subtitleConfigurations = subtitleConfigurations,
|
||||||
)
|
)
|
||||||
|
|
||||||
return@withContext mediaItem
|
return@withContext mediaItem
|
||||||
@@ -283,6 +291,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
artworkUrl: String,
|
artworkUrl: String,
|
||||||
playbackTag: Any?,
|
playbackTag: Any?,
|
||||||
|
subtitleConfigurations: List<MediaItem.SubtitleConfiguration> = emptyList(),
|
||||||
): MediaItem {
|
): MediaItem {
|
||||||
val metadata = MediaMetadata.Builder()
|
val metadata = MediaMetadata.Builder()
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -294,6 +303,9 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
.setMediaMetadata(metadata)
|
.setMediaMetadata(metadata)
|
||||||
.setTag(playbackTag)
|
.setTag(playbackTag)
|
||||||
|
if (subtitleConfigurations.isNotEmpty()) {
|
||||||
|
builder.setSubtitleConfigurations(subtitleConfigurations)
|
||||||
|
}
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import hu.bbara.purefin.core.data.PlaybackMethod
|
|||||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||||
import hu.bbara.purefin.core.data.QuickConnectSession
|
import hu.bbara.purefin.core.data.QuickConnectSession
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
|
||||||
import hu.bbara.purefin.data.jellyfin.etag.NotModifiedException
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -34,7 +32,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
|||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
@@ -69,12 +66,10 @@ import javax.inject.Singleton
|
|||||||
class JellyfinApiClient @Inject constructor(
|
class JellyfinApiClient @Inject constructor(
|
||||||
@ApplicationContext private val applicationContext: Context,
|
@ApplicationContext private val applicationContext: Context,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
|
|
||||||
) {
|
) {
|
||||||
private val jellyfin = createJellyfin {
|
private val jellyfin = createJellyfin {
|
||||||
context = applicationContext
|
context = applicationContext
|
||||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||||
apiClientFactory = okHttpFactory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val api = jellyfin.createApi()
|
val api = jellyfin.createApi()
|
||||||
@@ -219,21 +214,17 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logRequest("getLibraries") {
|
logRequest("getLibraries") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
val response = api.userViewsApi.getUserViews(
|
val response = api.userViewsApi.getUserViews(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||||
includeHidden = false,
|
includeHidden = false,
|
||||||
)
|
)
|
||||||
response.content.items
|
response.content.items
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,12 +238,11 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logRequest("getLibraryContent") {
|
logRequest("getLibraryContent") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
val getItemsRequest = GetItemsRequest(
|
val getItemsRequest = GetItemsRequest(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
@@ -264,52 +254,15 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
)
|
)
|
||||||
val response = api.itemsApi.getItems(getItemsRequest)
|
val response = api.itemsApi.getItems(getItemsRequest)
|
||||||
response.content.items
|
response.content.items
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
* Returns the total number of items in [libraryId] without enumerating
|
|
||||||
* them. Uses `getItems` with `limit=0, enableTotalRecordCount=true`,
|
|
||||||
* which the Jellyfin server short-circuits to a single integer in the
|
|
||||||
* response. The home page uses this to populate `Library.size` for the
|
|
||||||
* library cards on the dashboard without paying for the full library
|
|
||||||
* content.
|
|
||||||
*
|
|
||||||
* ETag is honored via the [ETagInterceptor]: `null` means the cached
|
|
||||||
* count is still current.
|
|
||||||
*/
|
|
||||||
suspend fun getLibraryItemCount(libraryId: UUID): Int? = withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getLibraryItemCount") {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return@logRequest 0
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val request = GetItemsRequest(
|
|
||||||
userId = getUserId(),
|
|
||||||
parentId = libraryId,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
|
||||||
recursive = true,
|
|
||||||
limit = 0,
|
|
||||||
enableTotalRecordCount = true,
|
|
||||||
)
|
|
||||||
val response = api.itemsApi.getItems(request)
|
|
||||||
response.content.totalRecordCount
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getSuggestions") {
|
logRequest("getSuggestions") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
}
|
||||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
val userId = getUserId() ?: return@logRequest emptyList()
|
||||||
try {
|
|
||||||
val response = api.suggestionsApi.getSuggestions(
|
val response = api.suggestionsApi.getSuggestions(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
mediaType = listOf(MediaType.VIDEO),
|
mediaType = listOf(MediaType.VIDEO),
|
||||||
@@ -318,19 +271,15 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
enableTotalRecordCount = true,
|
enableTotalRecordCount = true,
|
||||||
)
|
)
|
||||||
response.content.items
|
response.content.items
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logRequest("getContinueWatching") {
|
logRequest("getContinueWatching") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
}
|
||||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
val userId = getUserId() ?: return@logRequest emptyList()
|
||||||
try {
|
|
||||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||||
@@ -340,18 +289,14 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
)
|
)
|
||||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||||
response.content.items
|
response.content.items
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logRequest("getNextUpEpisodes") {
|
logRequest("getNextUpEpisodes") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
throw IllegalStateException("Not configured")
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
val getNextUpRequest = GetNextUpRequest(
|
val getNextUpRequest = GetNextUpRequest(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||||
@@ -359,18 +304,14 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
)
|
)
|
||||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||||
result.content.items
|
result.content.items
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
logRequest("getLatestFromLibrary") {
|
logRequest("getLatestFromLibrary") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
val response = api.userLibraryApi.getLatestMedia(
|
val response = api.userLibraryApi.getLatestMedia(
|
||||||
userId = getUserId(),
|
userId = getUserId(),
|
||||||
parentId = libraryId,
|
parentId = libraryId,
|
||||||
@@ -379,108 +320,6 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
limit = 15,
|
limit = 15,
|
||||||
)
|
)
|
||||||
response.content
|
response.content
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Head-only fetch for the suggestions row. Returns the first [limit]
|
|
||||||
* items so the caller can diff against the cached head and decide
|
|
||||||
* whether the full request is necessary. ETag is honored via the
|
|
||||||
* [ETagInterceptor]; `null` means the cached head is still current.
|
|
||||||
*/
|
|
||||||
suspend fun getSuggestionsHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getSuggestionsHead") {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val response = api.suggestionsApi.getSuggestions(
|
|
||||||
userId = getUserId(),
|
|
||||||
mediaType = listOf(MediaType.VIDEO),
|
|
||||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
|
||||||
limit = limit,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
response.content.items
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Head-only fetch for the continue-watching row. See [getSuggestionsHead].
|
|
||||||
*/
|
|
||||||
suspend fun getContinueWatchingHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getContinueWatchingHead") {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
|
||||||
}
|
|
||||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
|
||||||
try {
|
|
||||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
|
||||||
userId = userId,
|
|
||||||
fields = defaultItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
|
||||||
enableUserData = true,
|
|
||||||
startIndex = 0,
|
|
||||||
limit = limit,
|
|
||||||
)
|
|
||||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
|
||||||
response.content.items
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Head-only fetch for the next-up row. See [getSuggestionsHead].
|
|
||||||
*/
|
|
||||||
suspend fun getNextUpHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getNextUpHead") {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val getNextUpRequest = GetNextUpRequest(
|
|
||||||
userId = getUserId(),
|
|
||||||
fields = defaultItemFields,
|
|
||||||
enableResumable = false,
|
|
||||||
limit = limit,
|
|
||||||
)
|
|
||||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
|
||||||
result.content.items
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Head-only fetch for the latest row of a library. See [getSuggestionsHead].
|
|
||||||
*/
|
|
||||||
suspend fun getLatestFromLibraryHead(libraryId: UUID, limit: Int = HEAD_LIMIT): List<BaseItemDto>? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
logRequest("getLatestFromLibraryHead") {
|
|
||||||
if (!ensureConfigured()) {
|
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val response = api.userLibraryApi.getLatestMedia(
|
|
||||||
userId = getUserId(),
|
|
||||||
parentId = libraryId,
|
|
||||||
fields = defaultItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
|
||||||
limit = limit,
|
|
||||||
)
|
|
||||||
response.content
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,9 +635,5 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "JellyfinApiClient"
|
private const val TAG = "JellyfinApiClient"
|
||||||
// How many items to fetch on the head-only path. Two is enough to
|
|
||||||
// detect the common case of "first card unchanged" without making
|
|
||||||
// the head request meaningfully heavier than the minimum.
|
|
||||||
const val HEAD_LIMIT = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory store of `ETag` response headers, keyed by full request URL. The
|
|
||||||
* [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing
|
|
||||||
* requests and writes to it on every successful response.
|
|
||||||
*
|
|
||||||
* ETag handling is scoped to a small set of home-refresh URL patterns
|
|
||||||
* matched against path segments. All other endpoints (per-item lookups,
|
|
||||||
* search, sessions, system info, etc.) are passed through unchanged. This
|
|
||||||
* keeps the 304 → [NotModifiedException] translation from leaking into code
|
|
||||||
* paths that do not expect it, and prevents search and similar list-shaped
|
|
||||||
* calls from being treated as cacheable.
|
|
||||||
*
|
|
||||||
* The cache is process-scoped: ETags are lost on process death, which means
|
|
||||||
* the first request after each app start always goes out without
|
|
||||||
* `If-None-Match`. This is acceptable — the server returns the same data
|
|
||||||
* with a fresh ETag, and subsequent requests benefit from the cache.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ETagCache @Inject constructor() {
|
|
||||||
private val etags = ConcurrentHashMap<String, String>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns `true` if requests to [url] should use ETag-based conditional
|
|
||||||
* fetching. Only home-refresh endpoints are eligible, and the
|
|
||||||
* `/Items` list endpoint is only eligible when it is being used as a
|
|
||||||
* per-library content query (a `parentId` query parameter is present
|
|
||||||
* and no search-shaped parameters are set).
|
|
||||||
*/
|
|
||||||
fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the cached ETag for [url], or `null` if none has been stored
|
|
||||||
* yet.
|
|
||||||
*/
|
|
||||||
fun get(url: String): String? = etags[url]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache [etag] for [url]. The interceptor writes here after every
|
|
||||||
* eligible 2xx response that carried an ETag header.
|
|
||||||
*/
|
|
||||||
fun put(url: String, etag: String) {
|
|
||||||
etags[url] = etag
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesHomeRefresh(url: HttpUrl): Boolean {
|
|
||||||
val segments = url.pathSegments
|
|
||||||
return when {
|
|
||||||
// /Users/{userId}/Views — get libraries
|
|
||||||
segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true
|
|
||||||
// /Items/Suggestions — get suggestions
|
|
||||||
segments == listOf("Items", "Suggestions") -> true
|
|
||||||
// /Users/{userId}/Items/Resume — get resume items (continue watching)
|
|
||||||
segments.size == 4 &&
|
|
||||||
segments[0] == "Users" &&
|
|
||||||
segments[2] == "Items" &&
|
|
||||||
segments[3] == "Resume" -> true
|
|
||||||
// /Shows/NextUp — get next up episodes
|
|
||||||
segments == listOf("Shows", "NextUp") -> true
|
|
||||||
// /Users/{userId}/Items/Latest — latest items in a library
|
|
||||||
segments.size == 4 &&
|
|
||||||
segments[0] == "Users" &&
|
|
||||||
segments[2] == "Items" &&
|
|
||||||
segments[3] == "Latest" -> true
|
|
||||||
// /Items — per-library content list, distinguished from search
|
|
||||||
// by the presence of `parentId` and the absence of search params.
|
|
||||||
segments == listOf("Items") -> isPerLibraryItemsQuery(url)
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean {
|
|
||||||
// The per-library content call always sets parentId; the search
|
|
||||||
// calls set searchTerm or genres but never parentId. Matching
|
|
||||||
// on parentId alone is enough to keep the ETag scope tight.
|
|
||||||
return url.queryParameter("parentId") != null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OkHttp interceptor that turns Jellyfin's ETag support into a
|
|
||||||
* "not modified" signal the SDK caller can act on.
|
|
||||||
*
|
|
||||||
* For URLs whose path is in [ETagCache.isEligible]:
|
|
||||||
* - If a cached ETag exists, the request is sent with `If-None-Match`.
|
|
||||||
* - If the server replies 304, the response is closed and a
|
|
||||||
* [NotModifiedException] is thrown so the caller can keep using its
|
|
||||||
* existing copy of the data.
|
|
||||||
* - If the server replies 2xx with an ETag header, the new ETag is cached.
|
|
||||||
*
|
|
||||||
* All other URLs are passed through untouched. This bounds the surface
|
|
||||||
* area of the 304 handling: only code paths that explicitly know about
|
|
||||||
* the home-refresh contract need to catch [NotModifiedException].
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ETagInterceptor @Inject constructor(
|
|
||||||
private val cache: ETagCache,
|
|
||||||
) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
|
|
||||||
if (!cache.isEligible(request.url)) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = request.url.toString()
|
|
||||||
val etag = cache.get(url)
|
|
||||||
val outgoing = if (etag != null) {
|
|
||||||
request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build()
|
|
||||||
} else {
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = chain.proceed(outgoing)
|
|
||||||
|
|
||||||
if (response.code == HTTP_NOT_MODIFIED) {
|
|
||||||
// Close before throwing so OkHttp does not warn about leaked
|
|
||||||
// responses. The caller will treat this as "use the cached copy".
|
|
||||||
response.close()
|
|
||||||
Timber.tag(TAG).d("ETag hit for %s", url)
|
|
||||||
throw NotModifiedException()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the ETag for next time. Only successful responses carry
|
|
||||||
// meaningful ETags; error responses may carry caching hints but
|
|
||||||
// we should not treat a 5xx body as a valid cache key.
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.header(HEADER_ETAG)?.let { newEtag ->
|
|
||||||
cache.put(url, newEtag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ETagInterceptor"
|
|
||||||
const val HEADER_ETAG = "ETag"
|
|
||||||
const val HEADER_IF_NONE_MATCH = "If-None-Match"
|
|
||||||
const val HTTP_NOT_MODIFIED = 304
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the
|
|
||||||
* [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with
|
|
||||||
* [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient
|
|
||||||
* in `core.jellyfin.JellyfinNetworkModule`, which is used for image and
|
|
||||||
* media streaming. The Jellyfin SDK provides its own auth header on every
|
|
||||||
* request, so the SDK-bound client does not need `JellyfinAuthInterceptor`.
|
|
||||||
*/
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object JellyfinOkHttpModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@JellyfinSdkClient
|
|
||||||
fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient =
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.addInterceptor(etagInterceptor)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@JellyfinSdkClient
|
|
||||||
fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory =
|
|
||||||
OkHttpFactory(base = client)
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified
|
|
||||||
* for a request that had an `If-None-Match` header. Callers should treat this
|
|
||||||
* as "the cached copy is still valid" and avoid re-processing the response.
|
|
||||||
*
|
|
||||||
* This is a control-flow signal, not an error — it propagates out of
|
|
||||||
* `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only
|
|
||||||
* URLs that have been registered in [ETagCache] can produce this exception.
|
|
||||||
*/
|
|
||||||
class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified")
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -91,7 +91,6 @@ data class HomeCache(
|
|||||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
||||||
val libraries: List<CachedLibrary> = emptyList(),
|
val libraries: List<CachedLibrary> = emptyList(),
|
||||||
val libraryDateLastMediaAdded: Map<String, String> = emptyMap(),
|
|
||||||
val movies: List<CachedMovie> = emptyList(),
|
val movies: List<CachedMovie> = emptyList(),
|
||||||
val series: List<CachedSeries> = emptyList(),
|
val series: List<CachedSeries> = emptyList(),
|
||||||
val episodes: List<CachedEpisode> = emptyList()
|
val episodes: List<CachedEpisode> = emptyList()
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import hu.bbara.purefin.model.Episode
|
|||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
|
import hu.bbara.purefin.model.UNCATEGORIZED_LABEL
|
||||||
|
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -78,6 +80,23 @@ class OfflineRoomMediaLocalDataSource(
|
|||||||
episodeDao.upsert(episode.toEntity())
|
episodeDao.upsert(episode.toEntity())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (series.uncategorizedEpisodes.isNotEmpty()) {
|
||||||
|
val unwatched = series.uncategorizedEpisodes.count { !it.watched }
|
||||||
|
val sentinelSeason = Season(
|
||||||
|
id = UNCATEGORIZED_SEASON_ID,
|
||||||
|
seriesId = series.id,
|
||||||
|
name = UNCATEGORIZED_LABEL,
|
||||||
|
index = 0,
|
||||||
|
unwatchedEpisodeCount = unwatched,
|
||||||
|
episodeCount = series.uncategorizedEpisodes.size,
|
||||||
|
episodes = series.uncategorizedEpisodes,
|
||||||
|
)
|
||||||
|
seasonDao.upsert(sentinelSeason.toEntity())
|
||||||
|
series.uncategorizedEpisodes.forEach { episode ->
|
||||||
|
episodeDao.upsert(episode.toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +113,19 @@ class OfflineRoomMediaLocalDataSource(
|
|||||||
seriesDao.getById(episode.seriesId)
|
seriesDao.getById(episode.seriesId)
|
||||||
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
|
||||||
|
|
||||||
|
if (episode.seasonId == UNCATEGORIZED_SEASON_ID && seasonDao.getById(UNCATEGORIZED_SEASON_ID) == null) {
|
||||||
|
seasonDao.upsert(
|
||||||
|
SeasonEntity(
|
||||||
|
id = UNCATEGORIZED_SEASON_ID,
|
||||||
|
seriesId = episode.seriesId,
|
||||||
|
name = UNCATEGORIZED_LABEL,
|
||||||
|
index = 0,
|
||||||
|
unwatchedEpisodeCount = 0,
|
||||||
|
episodeCount = 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
episodeDao.upsert(episode.toEntity())
|
episodeDao.upsert(episode.toEntity())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +197,9 @@ class OfflineRoomMediaLocalDataSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
suspend fun getSeasons(seriesId: UUID): List<Season> {
|
||||||
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
|
return seasonDao.getBySeriesId(seriesId)
|
||||||
|
.filter { it.id != UNCATEGORIZED_SEASON_ID }
|
||||||
|
.map { seasonEntity ->
|
||||||
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
|
||||||
episodeEntity.toDomain()
|
episodeEntity.toDomain()
|
||||||
}
|
}
|
||||||
@@ -277,7 +311,10 @@ class OfflineRoomMediaLocalDataSource(
|
|||||||
cast = emptyList()
|
cast = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series(
|
private fun SeriesEntity.toDomain(seasons: List<Season>): Series {
|
||||||
|
val (realSeasons, sentinelSeason) = seasons.partition { it.id != UNCATEGORIZED_SEASON_ID }
|
||||||
|
val uncategorizedEpisodes = sentinelSeason.flatMap { it.episodes }
|
||||||
|
return Series(
|
||||||
id = id,
|
id = id,
|
||||||
libraryId = libraryId,
|
libraryId = libraryId,
|
||||||
name = name,
|
name = name,
|
||||||
@@ -286,9 +323,11 @@ class OfflineRoomMediaLocalDataSource(
|
|||||||
imageUrlPrefix = imageUrlPrefix,
|
imageUrlPrefix = imageUrlPrefix,
|
||||||
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
unwatchedEpisodeCount = unwatchedEpisodeCount,
|
||||||
seasonCount = seasonCount,
|
seasonCount = seasonCount,
|
||||||
seasons = seasons,
|
seasons = realSeasons,
|
||||||
cast = emptyList()
|
uncategorizedEpisodes = uncategorizedEpisodes,
|
||||||
|
cast = emptyList(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
|
|||||||
android.r8.strictFullModeForKeepRules=false
|
android.r8.strictFullModeForKeepRules=false
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
purefinReleaseVersionCode=1000030
|
purefinReleaseVersionCode=1000037
|
||||||
purefinDebugVersionCode=1000003
|
purefinDebugVersionCode=1000003
|
||||||
purefinTvVersionCode=2000014
|
purefinTvVersionCode=2000023
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.3.0"
|
||||||
coreKtx = "1.15.0"
|
coreKtx = "1.19.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.2.1"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.6.1"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.8.7"
|
lifecycleRuntimeKtx = "2.11.0"
|
||||||
activityCompose = "1.9.3"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.4.10"
|
||||||
composeBom = "2025.02.00"
|
composeBom = "2026.06.01"
|
||||||
jellyfin-core = "1.8.8"
|
jellyfin-core = "1.8.11"
|
||||||
hilt = "2.57.2"
|
hilt = "2.60.1"
|
||||||
hiltNavigationCompose = "1.2.0"
|
hiltNavigationCompose = "1.4.0"
|
||||||
ksp = "2.3.2"
|
ksp = "2.3.10"
|
||||||
datastore = "1.1.1"
|
datastore = "1.2.1"
|
||||||
kotlinxSerializationJson = "1.7.3"
|
kotlinxSerializationJson = "1.11.0"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.11.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "5.4.0"
|
||||||
foundation = "1.10.1"
|
foundation = "1.11.4"
|
||||||
tvMaterial = "1.0.1"
|
tvMaterial = "1.1.0"
|
||||||
coil = "3.3.0"
|
coil = "3.5.0"
|
||||||
media3 = "1.9.0"
|
media3 = "1.10.1"
|
||||||
media3FfmpegDecoder = "1.9.0+1"
|
media3FfmpegDecoder = "1.9.0+1"
|
||||||
nav3Core = "1.0.0"
|
nav3Core = "1.1.4"
|
||||||
room = "2.7.0"
|
room = "2.8.4"
|
||||||
slf4j = "2.0.17"
|
slf4j = "2.0.18"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
firebaseCrashlytics = "20.0.5"
|
firebaseCrashlytics = "20.1.0"
|
||||||
googleGmsGoogleServices = "4.4.4"
|
googleGmsGoogleServices = "4.5.0"
|
||||||
googleFirebaseCrashlytics = "3.0.7"
|
googleFirebaseCrashlytics = "3.0.7"
|
||||||
foundationVersion = "1.11.2"
|
animation = "1.11.4"
|
||||||
animation = "1.11.2"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -66,6 +65,8 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref
|
|||||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||||
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
|
medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"}
|
||||||
medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3"}
|
medi3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3"}
|
||||||
|
media3-exoplayer-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3"}
|
||||||
|
media3-exoplayer-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3"}
|
||||||
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"}
|
||||||
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
|
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
|
||||||
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
|
medi3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "media3FfmpegDecoder"}
|
||||||
@@ -78,10 +79,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
|
|||||||
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
|
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
|
||||||
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
|
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
|
||||||
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
|
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
|
||||||
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" }
|
|
||||||
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
|
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
|
||||||
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
|||||||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,8 +1,8 @@
|
|||||||
#Thu Jan 15 19:48:32 CET 2026
|
#Thu Jan 15 19:48:32 CET 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
|
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
1
graphify-out/.graphify_labels.json
Normal file
1
graphify-out/.graphify_labels.json
Normal 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"}
|
||||||
1
graphify-out/.graphify_python
Normal file
1
graphify-out/.graphify_python
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/home/.local/share/pipx/venvs/graphifyy/bin/python
|
||||||
1
graphify-out/.graphify_root
Normal file
1
graphify-out/.graphify_root
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/home/repositories/Purefin
|
||||||
883
graphify-out/GRAPH_REPORT.md
Normal file
883
graphify-out/GRAPH_REPORT.md
Normal 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
@@ -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"}]}
|
||||||
@@ -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
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
12
graphify-out/cost.json
Normal 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
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
81104
graphify-out/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
1747
graphify-out/manifest.json
Normal file
1747
graphify-out/manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user