Compare commits

...

77 Commits

Author SHA1 Message Date
80f25c6dea chore(build): increment app-tv versionCode to 2000014 2026-06-19 19:49:08 +02:00
b19d41abc6 chore(build): increment app versionCode to 1000028 2026-06-19 19:43:58 +02:00
831abfebb4 feat(tv): make hero responsive to screen height and enrich metadata 2026-06-19 17:04:55 +00:00
eb540a0fbb fix(tv): constrain media detail background gradients to image size 2026-06-19 16:16:52 +00:00
29c48a50b6 fix(player): disable swipe gestures when player controls are shown 2026-06-19 18:11:19 +02:00
05f58295ad fix(player): disable swipe gestures when player controls are shown 2026-06-19 18:11:13 +02:00
31f9fe5d4c refactor(ui): drop focus border from circular buttons
- Remove focusedBorderWidth/focusedBorderColor from CircularIconButton
- Add new CircularTextButton component
- Replace skip segment FilledTonalButton with CircularTextButton
- Clean up unused imports
2026-06-19 14:32:18 +00:00
1ec9ff9c95 perf(home): head-fetch rows and count-only libraries
Add a head-only fetch path for the home rows (Suggestions, Continue
Watching, Next Up, Latest per library). The first HEAD_LIMIT items
are fetched first; if the head matches the cached head, the full
request is skipped. Most home refreshes now only pay for the small
head request, not the full payload.

Replace per-library getLibraryContent calls in the home page with
getLibraryItemCount (limit=0, enableTotalRecordCount=true). The home
page only uses Library.size; the full movies/series lists are fetched
on demand by LibraryViewModel via the new loadLibraryContent method.
The per-row loaders now upsert full movie/series details into
onlineMediaRepository so the home viewmodel can still resolve them.
2026-06-19 13:46:21 +00:00
5491c381a3 feat(jellyfin): add ETag-based conditional HTTP caching for home refresh
Introduce an OkHttp interceptor that caches ETags from home-refresh
endpoints and throws NotModifiedException on 304, allowing callers to
reuse cached data. Wire it into the Jellyfin SDK via a dedicated
@JellyfinSdkClient OkHttpClient. Update API methods to return null on
304, and update InMemoryAppContentRepository to keep existing state
when null is returned. Persist dateLastMediaAdded from /UserViews in
HomeCache to short-circuit unchanged /Items/Latest calls.
2026-06-19 13:16:07 +00:00
34340c2bd4 feat(player): add controls visibility handling to PlayerGesturesLayer 2026-06-19 15:02:10 +02:00
f6c465656e perf(home): reduce redundant requests on home refresh
Drop a duplicate getLibraries call from loadLatestLibraryContent by
reusing the libraries already loaded earlier in the same refresh.

Add a 30s debounce to HomeRefreshCoordinator.onResumed so rapid
resume/tab-switch events coalesce into a single refresh; pull-to-refresh
bypasses the gate via onRefresh.

Rate-limit SyncPlaybackPositionsHomeRefreshSideEffect to once every
6h with a 25-item cap and a rotating window cursor, so users with many
downloads no longer fire one getItemInfo call per item on every resume.
2026-06-19 14:38:57 +02:00
54c895ff0f feat(player): track skippable segment type to gate intro and outro UI
Expose the active skippable segment type from the player state so the UI
can show the skip intro button only for intro segments and trigger the
next episode overlay when an outro segment is active.
2026-06-19 14:25:29 +02:00
a4267d85ce refactor(player): replace manual hidden UI visibility with ValueChangeTimedVisibility counters
- Replace hiddenSeekPreviewPositionMs with hiddenSeek counter for ValueChangeTimedVisibility
- Add resumeStopFeedbackCounter to decouple feedback visibility from isPlaying state
- Consolidate onPausePlayback/onResumePlayback into single onTogglePlayback
- Increase right D-pad seek from 10s to 30s
- Remove obsolete LaunchedEffect resetting hidden seek state
2026-06-19 11:42:16 +00:00
a07f9dd6e4 refactor(player): replace manual stop feedback visibility with ValueChangeTimedVisibility 2026-06-19 11:14:48 +00:00
50bbf5b81f fix(player): let focused overlay handle D-pad center instead of pausing
The root Box's onPreviewKeyEvent consumed KeyDown for D-pad
center/Enter in handleTvPlayerRootKeyEvent, pausing or resuming
playback and returning true before the event reached the focused
next-episode overlay. Pressing center on the overlay therefore
paused playback instead of starting the next episode.

Add a popupVisible flag so the root handler returns false for
center/Enter when a focusable overlay (skip intro / next episode) is
shown, letting the event propagate to the overlay's clickable. The
skip-intro action is preserved because the visible skip button now
handles activation through its own clickable.

Drop the now-unused onSkipSegment/hasSkippableSegment parameters and
the redundant focusable modifier on TvNextEpisodeOverlay (clickable
already makes it focusable).
2026-06-19 11:04:18 +00:00
1bd51d8a9f refactor(player): move controls auto-hide from ViewModel to composable layer 2026-06-18 20:05:35 +00:00
615e95db5c feat(player): add next episode overlay that appears 20s before episode end
Show an "Up Next" card with the next episode thumbnail, play overlay,
and title when within 20 seconds of the current episode ending.
Tapping it immediately starts the next episode from the playlist queue.

- Add nextEpisode field to PlayerUiState derived from queue
- Create NextEpisodeOverlay for mobile with artwork + title
- Create TvNextEpisodeOverlay for TV with D-pad focus support
- Integrate into both PlayerScreen and TvPlayerScreen
- Auto-focus overlay on TV when it appears
2026-06-18 16:34:39 +00:00
ccd5a7f50e refactor(series): remove unused Add button from series detail screen 2026-06-18 15:48:26 +00:00
d0149bf647 fix(search): auto-focus search field to open keyboard on screen open 2026-06-18 15:39:51 +00:00
9bea9a81ff chore(build): increment app versionCode to 1000027 2026-06-12 20:45:20 +02:00
8d74d0c5df refactor(media): remove LocalMediaUpdater interface in favor of MediaMetadataUpdater
Make LocalMediaRepository extend MediaMetadataUpdater directly instead of the
now-removed LocalMediaUpdater, eliminating the redundant intermediate interface.
Add no-op updatePlaybackPosition stubs to all LocalMediaRepository implementations
since server-side position tracking belongs in the remote layer of the composite.

JellyfinMediaMetadataUpdaterImpl continues to act as the composite that combines
LocalMediaRepository (local cache) with JellyfinApiClient (remote server) under
the single MediaMetadataUpdater contract.
2026-06-12 18:40:22 +00:00
e78a3700a6 refactor(media): consolidate metadata update functions into MediaMetadataUpdater
Replace the disparate JellyfinMediaMetadataUpdater, LocalMediaUpdater,
and direct API/repository calls for watched status and progress updates
with a single unified MediaMetadataUpdater interface and implementation.

The new implementation coordinates both local cache (in-memory/Room)
and remote server updates, so callers have a single point of entry
for all metadata mutations. markAsWatched now updates both server
and local state, fixing a gap where the UI would not reflect
watched changes until a full refresh.
2026-06-12 18:08:32 +00:00
def92ad111 feat(ui): add watched toggle to detail screens and home/library cards
Add mark-as-watched/unwatched bottom sheet actions to home screen
NextUp cards and library browse cards, and to library screen grid items.
Add a single-button watched toggle (with dual icon/color states) to
Movie, Episode, and Series detail screens. Inject
JellyfinMediaMetadataUpdater into all four affected ViewModels.
2026-06-12 17:47:14 +00:00
c92a8831a5 chore(build): increment app versionCode to 1000026 2026-06-12 18:35:45 +02:00
fe66926335 fix(playback): add offline fallback for next-up episode resolution
When offline, getNextUpPlayableMedias() only tried the Jellyfin API which silently returns an empty list on failure. This broke the queue population for offline playback, meaning no playlist was built and autoplay-next could never fire.

Add an offline fallback path that queries the Room database for the current episode's series, finds the next episodes ordered by (seasonIndex, index), and resolves them as downloaded PlayableMedia via getPlayableMedia().
2026-06-12 16:20:17 +00:00
4727cdcab1 refactor(offline): rename OfflineCatalogStore to OfflineMediaManager and consolidate into repository
- Rename interface OfflineCatalogStore → OfflineMediaManager for clarity

- Merge RoomOfflineCatalogStore functionality into OfflineLocalMediaRepository

- Remove RoomOfflineCatalogStore; update DI binding and consumers
2026-06-12 16:11:34 +00:00
2853609cbb chore: remove unused interface 2026-06-12 17:53:15 +02:00
327e68af19 fix(playback): resume offline playback from saved position instead of start
Offline downloaded media always started playback from the beginning because getOfflineDownloadedPlayableMedia() hardcoded resumePositionMs to 0L, ignoring the progress percentage stored in the Room database.

Added calculateOfflineResumePosition() that parses the runtime string and converts the stored progress percentage (0-100) to milliseconds, applying the same 5%-95% threshold as the online path.
2026-06-12 12:30:23 +00:00
8dcb6aeea9 chore(build): increment app versionCode to 1000025 2026-06-07 13:35:49 +02:00
df2a468e89 feat(playback): mark items fully watched when progress exceeds 80%
When playback position reaches 80% of runtime, send the full runtimeTicks to the server so items appear completely watched, matching the played flag already being set at this threshold.
2026-06-07 11:30:33 +00:00
4a6977eefb feat(connectivity): add offline resilience with server reachability checks 2026-06-07 11:25:59 +00:00
3f9fd6e36e feat(card): replace dropdown menu with modal bottom sheet and add long-press
Replace the DropdownMenu popup in MediaImageCard with a ModalBottomSheet
that appears when the three-dot icon is pressed on ContinueWatchingCard
items. Also trigger the bottom sheet on long-press of the card.
Follows the existing ModalBottomSheet pattern from SeriesComponents.
2026-06-07 09:40:20 +00:00
10e7b450f3 fix(series): make season selector bottom sheet scrollable
Replace the non-scrollable Column with a LazyColumn in the season selector ModalBottomSheet so the sheet content scrolls when there are many seasons.
2026-06-06 17:37:47 +00:00
c81b228bb2 refactor(media): centralize detail scaffold content
Move duplicated media detail title, metadata, actions, synopsis, playback, and cast rendering behind MediaDetailScaffoldUiModel so screens only provide model data and optional trailing content.
2026-06-06 12:17:34 +00:00
f94bdc5bce feat(downloads): add DefaultTopBar to DownloadsScreen 2026-06-06 14:01:52 +02:00
4ade70a5de refactor(media): merge heroContent and content into single content lambda 2026-06-06 11:47:48 +00:00
ce907cee0c chore(build): increment app versionCode to 1000024 2026-06-06 13:28:35 +02:00
dd66302da0 feat(series): replace season tabs with BottomSheet selector
Convert SeasonTabs from horizontal scrollable tabs to a clickable label that opens a ModalBottomSheet listing all seasons for selection.

- Removes unused LazyRow, itemsIndexed, rememberLazyListState imports
- Removes unused SeasonTab private composable and SeasonTabTagPrefix constant
- Adds ArrowDropDown and Check icon imports
- Adds OptIn(ExperimentalMaterial3Api) for ModalBottomSheet
2026-06-06 10:51:12 +00:00
753b461453 refactor(series): remove unused preview functions and imports from SeriesScreen 2026-06-06 12:40:07 +02:00
0bf968a42b feat(ui): make top bars scroll aware 2026-06-06 10:38:40 +00:00
005b3249ff refactor(series): remove unused PlayCircle icon from SeriesComponents 2026-06-06 11:40:21 +02:00
84157868d0 feat(series): show episodes as vertical list 2026-06-06 09:38:15 +00:00
25b65b9048 refactor(app/ui): refactor MediaDetailScaffold
Move everything into the Scaffold for simpler scrolling behavior

pass Modifier to heroContent and content composables
2026-06-06 11:30:03 +02:00
ed418a8e44 chore(build): increment app-tv versionCode to 2000013 2026-06-05 18:41:34 +02:00
fecc962950 chore: update update manifest URL to secure HTTPS 2026-06-05 18:41:16 +02:00
1ae2541773 chore(build): increment app versionCode to 1000023 2026-06-05 18:28:08 +02:00
88e8b3a41f refactor(download): share download UI handling 2026-06-05 15:29:06 +00:00
85c53f8704 feat(playback): sync offline positions with Jellyfin
Add a home refresh side effect that compares offline progress with Jellyfin user data and syncs the furthest playback position.

Use Jellyfin playbackPositionTicks for server updates while keeping percent-based writes for local offline and in-memory repositories.
2026-06-05 14:57:20 +00:00
448550b5ab chore: remove unused code 2026-06-05 14:01:50 +02:00
9f8292b04d refactor(home): extract refresh side effects
Centralize home refresh orchestration so future refresh side effects can be added through Hilt multibindings instead of expanding AppViewModel.
2026-06-04 19:26:47 +00:00
fa87005d01 chore: update Gradle and AGP versions 2026-06-04 20:59:54 +02:00
1ad7bc4e23 feat: add exit confirmation dialog on back navigation 2026-06-04 20:57:38 +02:00
e72c421294 feat(playback): pause media playback when screen is in the background 2026-06-04 17:39:59 +02:00
9aec271aa4 fix(playback): manage playback state on activity lifecycle events 2026-06-04 17:39:59 +02:00
717bf3c8e1 fix(playback): Add clamp effect to brightness sliders 2026-06-03 20:59:47 +02:00
0011f3842a refactor(playback): simplify transcoding fallback state 2026-06-01 19:37:04 +00:00
670e937566 chore(build): increment app-tv versionCode to 2000012 2026-06-01 21:27:34 +02:00
38f1f94956 fix(playback): fall back to transcoding on direct play errors 2026-06-01 21:25:21 +02:00
72cb574753 chore(build): increment app versionCode to 1000022 2026-06-01 21:24:22 +02:00
80ab591662 Simplify Jellyfin playback url retrieval 2026-06-01 20:54:20 +02:00
0eea3165de PlaybackProfilePolicy should not require server version 2026-06-01 20:06:55 +02:00
b1b154800c Code cleanup 2026-06-01 20:02:27 +02:00
5c1ddffe7a refactor(playback): simplify getVideoStreamUrl usage and improve direct play handling 2026-05-29 18:09:12 +02:00
97dcea57ec chore(build): increment app versionCode to 1000021 2026-05-27 20:36:31 +02:00
09a1bb2288 fix(profile): enable ASS direct play by default in mobile profile config 2026-05-27 20:36:04 +02:00
8064367e7a Revert "feat(player): enhance video aspect ratio handling and resize mode logic"
This reverts commit b7186be4
2026-05-27 18:40:44 +02:00
dedd4f1da5 fix(settings): update title text style to use titleMedium 2026-05-25 21:27:20 +02:00
e58afd26d5 chore(build): increment app versionCode to 1000020 2026-05-25 21:08:54 +02:00
15c1e806da chore(build): increment app-tv versionCode to 2000011 2026-05-25 20:57:18 +02:00
194052517b feat(api): enable user data retrieval and enhance logging for result sizes 2026-05-25 20:54:43 +02:00
b0ed32ef7c feat(series): improve season and episode loading handling 2026-05-25 20:40:10 +02:00
3cd706d449 feat(tvseries): enhance episode focus handling and scrolling behavior 2026-05-25 19:59:16 +02:00
1dedc7ff0a Revert "feat(network): enhance connectivity monitoring with request reporting"
This reverts commit 56a2b42434.
2026-05-25 17:59:01 +02:00
285fda9caf chore(build): increment app-tv versionCode to 2000010 2026-05-25 12:46:08 +02:00
3e94e8193a chore(build): increment app versionCode to 1000019 2026-05-25 12:13:59 +02:00
Barnabás Balogh
0a56f46da7 Merge pull request #4 from bbara04/feat/downloaded-items-series
feat(downloads): update downloaded item handling
2026-05-24 15:18:24 +02:00
b226eaeba2 feat(downloads): update downloaded item handling 2026-05-23 18:45:27 +00:00
115 changed files with 4219 additions and 3353 deletions

View File

@@ -57,7 +57,9 @@ dependencies {
implementation(project(":core"))
implementation(project(":core-model"))
implementation(project(":core-ui"))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.foundation)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)

View File

@@ -1,979 +0,0 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme
import kotlinx.coroutines.delay
import org.junit.Rule
import org.junit.Test
private const val TvPlayerRootTag = "tv_player_root"
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
@OptIn(ExperimentalTestApi::class)
class TvPlayerControlsOverlayTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
requestFocus = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.Focused,
false
)
)
}
@Test
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"collapsed"
)
)
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
}
@Test
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"expanded"
)
)
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
}
@Test
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"collapsed"
)
)
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
}
@Test
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
composeRule.setContent {
AppTheme {
OverlayHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"expanded"
)
)
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
}
@Test
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState(),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
composeRule.onNodeWithText("Current").assertIsDisplayed()
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
}
@Test
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
composeRule.setContent {
AppTheme {
OverlayHost(
uiState = samplePlayerState().copy(
queue = listOf(
PlaylistElementUiModel(
id = "first",
title = "Episode 1",
subtitle = "Fallback entry",
artworkUrl = null,
isCurrent = false
),
PlaylistElementUiModel(
id = "second",
title = "Episode 2",
subtitle = "Later",
artworkUrl = null,
isCurrent = false
)
)
),
initiallyExpanded = true
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
}
@Test
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
}
@Test
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
)
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
}
@Test
fun backOnVisibleControls_hidesOverlayImmediately() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.Back)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
}
@Test
fun escapeOnVisibleControls_hidesOverlayImmediately() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.assertIsFocused()
.performKeyInput {
pressKey(Key.Escape)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
}
@Test
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.Enter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
composeRule.mainClock.advanceTimeBy(1)
composeRule.mainClock.advanceTimeBy(300)
composeRule.mainClock.advanceTimeByFrame()
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.mainClock.autoAdvance = true
}
@Test
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(isPlaying = false),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"0"
)
)
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"1"
)
)
}
@Test
fun hiddenControls_leftSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionLeft)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"-10000"
)
)
}
@Test
fun hiddenControls_rightSeeksWithoutShowingControls() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState(),
initialControlsVisible = false
)
}
}
composeRule.onNodeWithTag(TvPlayerRootTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription,
"10000"
)
)
}
@Test
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.Back)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
}
@Test
fun leftOnTrackPanel_keepsFocusInsidePanel() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionLeft)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
}
@Test
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
composeRule.setContent {
AppTheme {
TrackPanelHost(uiState = samplePlayerState())
}
}
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
}
@Test
fun trackButtonsWithoutOptions_areDisabled() {
composeRule.setContent {
AppTheme {
TrackPanelHost(
uiState = samplePlayerState().copy(
audioTracks = emptyList(),
textTracks = emptyList(),
qualityTracks = emptyList(),
selectedAudioTrackId = null,
selectedTextTrackId = null,
selectedQualityTrackId = null
)
)
}
}
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
}
}
@Composable
private fun OverlayHost(
uiState: PlayerUiState,
initiallyExpanded: Boolean = false,
requestFocus: Boolean = false
) {
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
val focusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
}
}
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onSkipSegment = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = {},
onOpenSubtitlesPanel = {},
onOpenQualityPanel = {},
onExpandPlaylist = { isPlaylistExpanded = true },
onCollapsePlaylist = { isPlaylistExpanded = false },
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
}
}
@Composable
private fun TrackPanelHost(
uiState: PlayerUiState,
initialControlsVisible: Boolean = true
) {
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
controlsVisible = true
}
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
pauseWithoutControlsCount += 1
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
resumeWithoutControlsCount += 1
stopFeedbackVisible = false
}
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
lastHiddenSeekDeltaMs = deltaMs
stopFeedbackVisible = false
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect
when (pendingFocus) {
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
}
pendingTrackButtonFocus = null
}
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(controlsVisible, trackPanelType) {
if (controlsVisible || trackPanelType != null) {
stopFeedbackVisible = false
} else {
rootFocusRequester.requestFocus()
}
}
Box(
modifier = Modifier
.size(width = 960.dp, height = 540.dp)
.focusRequester(rootFocusRequester)
.focusable()
.testTag(TvPlayerRootTag)
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
isPlaylistExpanded = false,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = { controlsVisible = true },
onHideControls = { controlsVisible = false },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekWithoutShowingControls,
onShowControls = { controlsVisible = true },
onTogglePlayPause = { controlsVisible = true }
)
}
) {
if (controlsVisible || trackPanelType != null) {
TvPlayerControlsOverlay(
uiState = uiState,
focusRequester = controlsFocusRequester,
isPlaylistExpanded = false,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = {},
onSeek = { _ -> },
onSeekRelative = { _ -> },
onSeekLiveEdge = {},
onSkipSegment = {},
onNext = {},
onPrevious = {},
onOpenAudioPanel = {
controlsVisible = true
trackPanelType = TvTrackPanelType.AUDIO
},
onOpenSubtitlesPanel = {
controlsVisible = true
trackPanelType = TvTrackPanelType.SUBTITLES
},
onOpenQualityPanel = {
controlsVisible = true
trackPanelType = TvTrackPanelType.QUALITY
},
onExpandPlaylist = {},
onCollapsePlaylist = {},
onSelectQueueItem = { _ -> },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
}
AnimatedVisibility(
visible = stopFeedbackVisible,
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
}
Box(
modifier = Modifier
.testTag(TvPlayerHiddenPauseCountTag)
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
)
Box(
modifier = Modifier
.testTag(TvPlayerHiddenResumeCountTag)
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
)
Box(
modifier = Modifier
.testTag(TvPlayerHiddenSeekDeltaTag)
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
)
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { closeTrackPanel() },
modifier = Modifier.fillMaxSize()
)
}
}
}
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
isPlaying = true,
title = "Sample Player",
subtitle = "Season 1",
durationMs = 3_600_000,
positionMs = 120_000,
bufferedMs = 240_000,
queue = listOf(
PlaylistElementUiModel(
id = "played",
title = "Already Played",
subtitle = "Episode 0",
artworkUrl = null,
isCurrent = false
),
PlaylistElementUiModel(
id = "current",
title = "Currently Playing",
subtitle = "Episode 1",
artworkUrl = null,
isCurrent = true
),
PlaylistElementUiModel(
id = "next",
title = "Episode 2",
subtitle = "Up next",
artworkUrl = null,
isCurrent = false
)
),
audioTracks = listOf(
TrackOption(
id = "audio_en",
label = "English 5.1",
language = "en",
bitrate = null,
channelCount = 6,
height = null,
groupIndex = 0,
trackIndex = 0,
type = TrackType.AUDIO,
isOff = false
),
TrackOption(
id = "audio_hu",
label = "Hungarian Stereo",
language = "hu",
bitrate = null,
channelCount = 2,
height = null,
groupIndex = 0,
trackIndex = 1,
type = TrackType.AUDIO,
isOff = false
)
),
textTracks = listOf(
TrackOption(
id = "text_off",
label = "Off",
language = null,
bitrate = null,
channelCount = null,
height = null,
groupIndex = 1,
trackIndex = -1,
type = TrackType.TEXT,
isOff = true
),
TrackOption(
id = "text_en",
label = "English CC",
language = "en",
bitrate = null,
channelCount = null,
height = null,
groupIndex = 1,
trackIndex = 0,
type = TrackType.TEXT,
isOff = false
)
),
qualityTracks = listOf(
TrackOption(
id = "quality_auto",
label = "Auto",
language = null,
bitrate = null,
channelCount = null,
height = 1080,
groupIndex = 2,
trackIndex = 0,
type = TrackType.VIDEO,
isOff = false
),
TrackOption(
id = "quality_720",
label = "720p",
language = null,
bitrate = null,
channelCount = null,
height = 720,
groupIndex = 2,
trackIndex = 1,
type = TrackType.VIDEO,
isOff = false
)
),
selectedAudioTrackId = "audio_hu",
selectedTextTrackId = "text_en",
selectedQualityTrackId = "quality_auto"
)

View File

@@ -29,7 +29,7 @@
android:theme="@style/Theme.Purefin">
<meta-data
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
android:value="http://purefin.t.bbara.hu/app-tv/update.json" />
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
<activity
android:name="hu.bbara.purefin.tv.TvActivity"
android:exported="true"

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.tv
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -50,6 +51,7 @@ import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
@@ -147,6 +149,7 @@ class TvActivity : ComponentActivity() {
updateViewModel: AppUpdateViewModel = hiltViewModel()
) {
var sessionLoaded by remember { mutableStateOf(false) }
var showExitDialog by remember { mutableStateOf(false) }
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -168,6 +171,9 @@ class TvActivity : ComponentActivity() {
}
if (!sessionLoaded) {
BackHandler {
showExitDialog = true
}
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
} else if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
@@ -179,7 +185,13 @@ class TvActivity : ComponentActivity() {
LaunchedEffect(navigationManager, backStack) {
navigationManager.commands.collect { command ->
when (command) {
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
NavigationCommand.Pop -> {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
showExitDialog = true
}
}
is NavigationCommand.Navigate -> backStack.add(command.route)
is NavigationCommand.ReplaceAll -> {
backStack.clear()
@@ -189,13 +201,23 @@ class TvActivity : ComponentActivity() {
}
}
BackHandler(enabled = backStack.size == 1) {
showExitDialog = true
}
CompositionLocalProvider(
LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList()
) {
NavDisplay(
backStack = backStack,
onBack = { navigationManager.pop() },
onBack = {
if (backStack.size > 1) {
navigationManager.pop()
} else {
showExitDialog = true
}
},
modifier = Modifier.fillMaxSize().background(backgroundDark),
transitionSpec = {
fadeIn(
@@ -229,6 +251,9 @@ class TvActivity : ComponentActivity() {
)
}
} else {
BackHandler {
showExitDialog = true
}
TvLoginScreen()
}
@@ -239,6 +264,13 @@ class TvActivity : ComponentActivity() {
onDecline = updateViewModel::declineUpdate
)
}
if (showExitDialog) {
ExitAppDialog(
onCloseConfirmed = { finishAndRemoveTask() },
onDismiss = { showExitDialog = false }
)
}
}
@Composable

View File

@@ -89,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
.fillMaxWidth()
.heightIn(min = bodyHeight)
) {
Box(modifier = Modifier.matchParentSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
.align(Alignment.TopEnd)
) {
PurefinAsyncImage(
model = backgroundImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
.align(Alignment.TopEnd),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.12f)
)
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.16f),
1.0f to scheme.background
)
)
)
)
}
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.12f)
)
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.16f),
1.0f to scheme.background
)
)
)
)
Column(
modifier = Modifier
.fillMaxWidth()

View File

@@ -5,8 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -81,8 +81,6 @@ internal fun MediaDetailsTopBar(
onClick = onBack,
modifier = backModifier.then(downModifier),
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
if (shortcut != null) {
@@ -100,8 +98,6 @@ internal fun MediaDetailsTopBar(
onClick = onCastClick,
modifier = downModifier,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
GhostIconButton(
@@ -110,8 +106,6 @@ internal fun MediaDetailsTopBar(
onClick = onMoreClick,
modifier = downModifier,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
}

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home
@@ -102,6 +103,10 @@ fun TvAppScreen(
}
}
BackHandler(enabled = backStack.size == 1) {
navigationManager.pop()
}
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,

View File

@@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.LibraryUiModel
@@ -40,8 +39,7 @@ fun TvHomeScreen(
modifier = Modifier.fillMaxSize()
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value,
height = 220.dp
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,

View File

@@ -6,9 +6,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
@@ -20,28 +21,31 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import kotlin.math.roundToInt
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180
// Half the screen for the billboard, half for the content rows. Tuned so both
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV.
internal const val TvHomeHeroHeightFraction = 0.5f
@Composable
internal fun TvFocusedItemHero(
item: MediaUiModel,
height: Dp,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.fillMaxHeight(heightFraction)
.background(scheme.background)
) {
Crossfade(
@@ -56,20 +60,22 @@ internal fun TvFocusedItemHero(
contentScale = ContentScale.Crop
)
}
// Darken the left side so the text block stays readable over any backdrop.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.06f)
0.0f to scheme.background.copy(alpha = 0.86f),
0.34f to scheme.background.copy(alpha = 0.62f),
0.66f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.04f)
)
)
)
)
// Strong bottom fade so the title and metadata are legible.
Box(
modifier = Modifier
.fillMaxSize()
@@ -77,7 +83,8 @@ internal fun TvFocusedItemHero(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f),
0.56f to scheme.background.copy(alpha = 0.1f),
0.40f to scheme.background.copy(alpha = 0.10f),
0.72f to scheme.background.copy(alpha = 0.58f),
1.0f to scheme.background
)
)
@@ -92,7 +99,7 @@ internal fun TvFocusedItemHero(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 18.dp)
.padding(horizontal = 40.dp, vertical = 16.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -101,23 +108,50 @@ internal fun TvFocusedItemHero(
Text(
text = hero.primaryText,
color = scheme.onBackground,
fontSize = 34.sp,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
maxLines = 1,
lineHeight = 44.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
Text(
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text(
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
@Composable
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
val hasSecondary = item.secondaryText.isNotBlank()
val progress = item.progress
val hasProgress = progress != null && progress > 0f && progress < 1f
if (!hasSecondary && !hasProgress) return
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (hasSecondary) {
TvHomeMetaChip(text = item.secondaryText)
}
if (hasProgress) {
val percent = ((progress ?: 0f) * 100f).roundToInt()
TvHomeMetaChip(
text = "Resume \u00B7 $percent%",
highlighted = true,
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
)
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -30,9 +31,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,33 +43,38 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
@@ -85,30 +92,43 @@ fun TvPlayerScreen(
viewModel.loadMedia(mediaId)
}
val scope = rememberCoroutineScope()
var hideControlsJob: Job? by remember { mutableStateOf(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
var controlsVisible by remember { mutableStateOf(true) }
var isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) }
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
var hiddenSeekRequestId by remember { mutableStateOf(0) }
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
// This is a hack for timed visibility.
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() }
val backgroundFocusRequester = remember { FocusRequester() }
// Main section focus requesters
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() }
val skipButtonFocusRequester = remember { FocusRequester() }
val nextEpisodeFocusRequester = remember { FocusRequester() }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
viewModel.pausePlayback()
}
DisposableEffect(Unit) {
onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity
if (uiState.isPlaying) {
@@ -117,105 +137,74 @@ fun TvPlayerScreen(
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
DisposableEffect(Unit) {
onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
fun showControls() {
controlsVisible = true
}
fun hideControls() {
controlsVisible = false
}
fun hideControlsWithTimeout() {
hideControlsJob?.cancel()
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
hideControlsJob = scope.launch {
delay(TV_CONTROLS_AUTO_HIDE_MS)
hideControls()
}
}
}
// Needed because of composition scheduling
LaunchedEffect(controlsVisible) {
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
rootFocusRequester.requestFocus()
}
}
LaunchedEffect(isPlaylistExpanded) {
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
fun expandPlaylist() {
// TODO: focus management for playlist expansion
isPlaylistExpanded = true
}
LaunchedEffect(trackPanelType) {
viewModel.setControlsAutoHideBlocked(
ControlsAutoHideBlocker.TRACK_PANEL,
trackPanelType != null
)
}
val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) {
isPlaylistExpanded = true
}
}
val collapsePlaylistToControls: () -> Unit = {
if (isPlaylistExpanded) {
isPlaylistExpanded = false
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
val showTvControls: () -> Unit = {
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
val togglePlayPauseAndShowControls: () -> Unit = {
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
}
val pausePlaybackWithoutShowingControls: () -> Unit = {
viewModel.pausePlayback()
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
viewModel.resumePlayback()
stopFeedbackVisible = false
}
val seekAndShowControls: (Long) -> Unit = { positionMs ->
viewModel.seekTo(positionMs)
showTvControls()
}
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
viewModel.seekBy(deltaMs)
showTvControls()
}
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
hiddenSeekRequestId += 1
viewModel.seekBy(deltaMs)
stopFeedbackVisible = false
}
val seekToLiveEdgeAndShowControls: () -> Unit = {
viewModel.seekToLiveEdge()
showTvControls()
}
val skipSegmentAndShowControls: () -> Unit = {
viewModel.skipActiveSegment()
showTvControls()
}
val nextAndShowControls: () -> Unit = {
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
}
val previousAndShowControls: () -> Unit = {
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
fun closePlaylist() {
// TODO focus
isPlaylistExpanded = false
}
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
}
LaunchedEffect(
controlsVisible,
controlsAutoHideBlocked,
uiState.activeSkippableSegmentEndMs
) {
if (controlsAutoHideBlocked) return@LaunchedEffect
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
if (uiState.activeSkippableSegmentEndMs != null) {
skipButtonFocusRequester.requestFocus()
return@LaunchedEffect
val showSkipIntroButton = !controlsVisible
&& uiState.activeSkippableSegmentEndMs != null
&& uiState.activeSkippableSegmentType == SegmentType.INTRO
&& !uiState.isEnded
val showNextEpisodeOverlay = !controlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& ((uiState.durationMs - uiState.positionMs) <= 30_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
&& !uiState.isEnded
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
rootFocusRequester = when {
showSkipIntroButton -> {
skipButtonFocusRequester
}
focusManager.clearFocus()
showNextEpisodeOverlay -> {
nextEpisodeFocusRequester
}
else -> {
backgroundFocusRequester
}
}
if (!controlsVisible) {
rootFocusRequester.requestFocus()
}
}
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect
@@ -227,29 +216,8 @@ fun TvPlayerScreen(
pendingTrackButtonFocus = null
}
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(hiddenSeekRequestId) {
if (hiddenSeekRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
hiddenSeekPreviewPositionMs = null
}
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
stopFeedbackVisible = false
hiddenSeekPreviewPositionMs = null
}
}
val playerControlsVisible =
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
@@ -258,8 +226,12 @@ fun TvPlayerScreen(
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility()
isPlaylistExpanded -> {
closePlaylist()
}
controlsVisible -> {
hideControls()
}
else -> onBack()
}
}
@@ -270,23 +242,32 @@ fun TvPlayerScreen(
.background(Color.Black)
.focusRequester(rootFocusRequester)
.onPreviewKeyEvent { event ->
handleTvPlayerRootKeyEvent(
val handled = handleTvPlayerRootKeyEvent(
event = event,
isPlaying = uiState.isPlaying,
controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls,
isPlaylistExpanded = isPlaylistExpanded,
trackPanelType = trackPanelType,
onCloseTrackPanel = closeTrackPanel,
onCollapsePlaylist = collapsePlaylistToControls,
onHideControls = { viewModel.toggleControlsVisibility() },
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
onSeekRelative = seekByWithoutShowingControls,
onShowControls = showTvControls,
onSkipSegment = skipSegmentAndShowControls,
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null,
onTogglePlayPause = togglePlayPauseAndShowControls
onCollapsePlaylist = {},
onHideControls = ::hideControls,
onTogglePlayback = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden resume/stop feedback.
resumeStopFeedbackCounter++
viewModel.togglePlayPause()
},
onSeekRelative = {
// This is a hack to trigger the ValueChangeTimedVisibility to show the hidden seek timeline.
hiddenSeekCounter++
viewModel.seekBy(it)
},
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
}
handled
}
.focusable()
) {
@@ -309,7 +290,7 @@ fun TvPlayerScreen(
)
AnimatedVisibility(
visible = playerControlsVisible,
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
@@ -321,21 +302,28 @@ fun TvPlayerScreen(
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = togglePlayPauseAndShowControls,
onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls,
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
onSkipSegment = skipSegmentAndShowControls,
onNext = nextAndShowControls,
onPrevious = previousAndShowControls,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { positionMs ->
viewModel.seekTo(positionMs)
},
onSeekRelative = { deltaMs ->
viewModel.seekBy(deltaMs)
},
onSeekLiveEdge = {
viewModel.seekToLiveEdge()
},
onSkipSegment = {
viewModel.skipActiveSegment()
},
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = expandPlaylist,
onCollapsePlaylist = collapsePlaylistToControls,
onExpandPlaylist = ::expandPlaylist,
onCollapsePlaylist = ::closePlaylist,
onSelectQueueItem = { id ->
viewModel.playQueueItem(id)
collapsePlaylistToControls()
},
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
@@ -344,20 +332,18 @@ fun TvPlayerScreen(
}
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
enter = fadeIn(),
exit = fadeOut(),
visible = showSkipIntroButton,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 24.dp,
bottom = if (hiddenSeekPreviewPositionMs != null) 104.dp else 24.dp
bottom = 24.dp
)
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = skipSegmentAndShowControls,
onClick = { viewModel.skipActiveSegment() },
size = 64,
label = "Skip",
modifier = Modifier.focusRequester(skipButtonFocusRequester)
@@ -365,15 +351,32 @@ fun TvPlayerScreen(
}
AnimatedVisibility(
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
enter = fadeIn(),
exit = fadeOut(),
visible = showNextEpisodeOverlay,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 32.dp,
bottom = 140.dp
)
) {
uiState.nextEpisode?.let { nextEpisode ->
TvNextEpisodeOverlay(
nextEpisode = nextEpisode,
onClick = { viewModel.next() },
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
)
}
}
ValueChangeTimedVisibility(
value = hiddenSeekCounter,
hideAfterMillis = 2500L,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 32.dp, vertical = 28.dp)
) {
HiddenTvSeekTimeline(
positionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs,
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
@@ -385,21 +388,20 @@ fun TvPlayerScreen(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = nextAndShowControls,
onNext = { viewModel.next() },
onReplay = {
viewModel.seekTo(0L)
togglePlayPauseAndShowControls()
viewModel.resumePlayback()
},
onDismissError = { viewModel.clearError() }
)
AnimatedVisibility(
visible = stopFeedbackVisible,
enter = fadeIn(),
exit = fadeOut(),
ValueChangeTimedVisibility(
value = resumeStopFeedbackCounter,
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerHiddenStopFeedback()
TvPlayerResumeStopFeedback(resume = uiState.isPlaying)
}
AnimatedVisibility(
@@ -463,29 +465,19 @@ private fun HiddenTvSeekTimeline(
}
}
private fun Long.coerceSeekPosition(durationMs: Long): Long =
if (durationMs > 0L) {
coerceIn(0L, durationMs)
} else {
coerceAtLeast(0L)
}
internal fun handleTvPlayerRootKeyEvent(
event: androidx.compose.ui.input.key.KeyEvent,
event: KeyEvent,
isPlaying: Boolean,
controlsVisible: Boolean,
popupVisible: Boolean,
isPlaylistExpanded: Boolean,
trackPanelType: TvTrackPanelType?,
onCloseTrackPanel: () -> Unit,
onCollapsePlaylist: () -> Unit,
onHideControls: () -> Unit,
onPausePlaybackWithoutShowingControls: () -> Unit,
onResumePlaybackWithoutShowingControls: () -> Unit,
onTogglePlayback: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
onSkipSegment: () -> Unit = {},
hasSkippableSegment: Boolean = false,
onTogglePlayPause: () -> Unit
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
@@ -518,7 +510,7 @@ internal fun handleTvPlayerRootKeyEvent(
}
Key.DirectionRight -> {
onSeekRelative(10_000)
onSeekRelative(30_000)
true
}
@@ -528,14 +520,13 @@ internal fun handleTvPlayerRootKeyEvent(
}
Key.DirectionCenter, Key.Enter -> {
if (hasSkippableSegment) {
onSkipSegment()
} else if (isPlaying) {
onPausePlaybackWithoutShowingControls()
if (popupVisible) {
// Do nothing because the focused component is not he root, but an overlay
false
} else {
onResumePlaybackWithoutShowingControls()
onTogglePlayback()
true
}
true
}
else -> false
@@ -546,7 +537,8 @@ internal fun handleTvPlayerRootKeyEvent(
}
@Composable
internal fun TvPlayerHiddenStopFeedback(
internal fun TvPlayerResumeStopFeedback(
resume: Boolean,
modifier: Modifier = Modifier
) {
Box(
@@ -558,8 +550,8 @@ internal fun TvPlayerHiddenStopFeedback(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Pause,
contentDescription = "Pause playback",
imageVector = if (resume) Icons.Outlined.PlayArrow else Icons.Outlined.Pause,
contentDescription = "Play/Pause playback",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(72.dp)
)

View File

@@ -0,0 +1,143 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
/**
* TV-optimized overlay button that appears near the end of a media item to let the user
* quickly jump to the next episode. Shows the next episode's artwork and title.
* Supports D-pad navigation and focus animations.
*/
@Composable
fun TvNextEpisodeOverlay(
nextEpisode: PlaylistElementUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.05f else 1f,
label = "nextEpisodeScale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary else Color.Transparent,
label = "nextEpisodeBorder"
)
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) {
scheme.surfaceContainerHigh.copy(alpha = 0.96f)
} else {
Color.Black.copy(alpha = 0.85f)
},
label = "nextEpisodeBackground"
)
Column(
modifier = modifier
.width(280.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.border(
width = if (isFocused) 3.dp else 0.dp,
color = borderColor,
shape = RoundedCornerShape(20.dp)
)
.clip(RoundedCornerShape(20.dp))
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// "Up Next" label
Text(
text = "Up Next",
color = scheme.secondary,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp)
)
Spacer(modifier = Modifier.height(6.dp))
// Thumbnail with play button overlay
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
) {
AsyncImage(
model = nextEpisode.artworkUrl?.takeIf { it.isNotEmpty() },
contentDescription = nextEpisode.title,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
)
// Dark gradient overlay for readability
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.1f),
Color.Black.copy(alpha = 0.4f)
)
)
)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Episode title
Text(
text = nextEpisode.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp)
)
}
}
internal const val TvPlayerNextEpisodeOverlayTag = "tv_player_next_episode_overlay"

View File

@@ -13,11 +13,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text as TvText
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
@@ -49,7 +51,7 @@ fun TvSeriesScreen(
val series = viewModel.series.collectAsStateWithLifecycle()
val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
if (seriesData != null) {
TvSeriesScreenContent(
series = seriesData,
onPlayEpisode = viewModel::onPlayEpisode,
@@ -74,25 +76,27 @@ internal fun TvSeriesScreenContent(
) {
val defaultSeason = series.defaultSeason(focusedSeasonId)
var selectedSeasonId by remember(series.id, focusedSeasonId) {
mutableStateOf(defaultSeason.id)
mutableStateOf(defaultSeason?.id)
}
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
val initialFocusSeasonId = remember(series.id, focusedSeasonId) { defaultSeason.id }
val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId)
val initialFocusSeasonId = defaultSeason?.id
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
series.seasons.firstOrNull { it.id == seasonId }
} ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
val seasonTabFocusRequester = remember { FocusRequester() }
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false)
}
var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false)
}
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
initialFocusSeason.episodes.isEmpty() &&
initialFocusSeason?.episodes?.isEmpty() == true &&
initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason.id) {
onLoadSeasonEpisodes(series.id, selectedSeason.id)
}
LaunchedEffect(series.id, initialFocusedEpisodeId, waitingForInitialEpisodes) {
if (initialFocusedEpisodeId != null || waitingForInitialEpisodes) return@LaunchedEffect
withFrameNanos { }
seasonTabFocusRequester.requestFocus()
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
}
TvMediaDetailScaffold(
@@ -114,32 +118,48 @@ internal fun TvSeriesScreenContent(
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
TvSeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason,
selectedItemFocusRequester = seasonTabFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeasonId = it.id },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
TvEpisodeCarousel(
episodes = selectedSeason.episodes,
onPlayEpisode = { onPlayEpisode(it.id) },
focusedEpisodeId = initialFocusedEpisodeId,
modifier = Modifier.fillMaxWidth()
)
if (selectedSeason != null) {
TvSeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason,
selectedItemFocusRequester = seasonTabFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
requestSelectedItemFocus = initialFocusedEpisodeId == null &&
!waitingForInitialEpisodes &&
!requestedInitialSeasonFocus,
onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
onSelect = { selectedSeasonId = it.id },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
TvEpisodeCarousel(
episodes = selectedSeason.episodes,
onPlayEpisode = { onPlayEpisode(it.id) },
focusedEpisodeId = initialFocusedEpisodeId,
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
onFocusRequested = { requestedInitialEpisodeFocus = true },
upFocusRequester = seasonTabFocusRequester,
modifier = Modifier.fillMaxWidth()
)
} else {
TvText(
text = "Loading seasons...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
}
}
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season {
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
if (focusedSeasonId != null) {
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
}
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.first()
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull()
}
private fun Season.nextUpEpisode(): Episode? {

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,9 +19,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -40,9 +41,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
@@ -50,9 +53,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.CastMember
@@ -78,30 +78,51 @@ internal fun TvSeriesMetaChips(series: Series) {
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun TvSeasonTabs(
seasons: List<Season>,
selectedSeason: Season?,
modifier: Modifier = Modifier,
selectedItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
requestSelectedItemFocus: Boolean = false,
onSelectedItemFocusRequested: () -> Unit = {},
onSelect: (Season) -> Unit
) {
val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0)
val listState = rememberLazyListState()
TabRow(
selectedTabIndex = selectedSeasonIndex,
LaunchedEffect(selectedSeasonIndex, requestSelectedItemFocus, seasons.size) {
if (seasons.isEmpty()) return@LaunchedEffect
listState.scrollToItem(selectedSeasonIndex)
if (requestSelectedItemFocus && selectedItemFocusRequester != null) {
withFrameNanos { }
selectedItemFocusRequester.requestFocus()
onSelectedItemFocusRequested()
}
}
LazyRow(
state = listState,
modifier = modifier
.focusRestorer()
.fillMaxWidth()
.wrapContentWidth(Alignment.Start),
contentColor = MaterialTheme.colorScheme.onSurface
.then(
if (selectedItemFocusRequester != null) {
Modifier.focusRestorer(selectedItemFocusRequester)
} else {
Modifier.focusRestorer()
}
)
.focusGroup()
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
seasons.forEachIndexed { index, season ->
Tab(
selected = index == selectedSeasonIndex,
onFocus = { onSelect(season) },
itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
TvSeasonTab(
name = season.name,
isSelected = index == selectedSeasonIndex,
onFocused = { onSelect(season) },
onClick = { onSelect(season) },
colors = TabDefaults.underlinedIndicatorTabColors(),
modifier = Modifier
.then(
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
@@ -117,60 +138,108 @@ internal fun TvSeasonTabs(
Modifier
}
)
) {
TvText(
text = season.name,
fontSize = 13.sp,
fontWeight = if (index == selectedSeasonIndex) {
FontWeight.Bold
} else {
FontWeight.Medium
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
)
}
}
}
@Composable
private fun TvSeasonTab(
name: String,
isSelected: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val color = if (isSelected) scheme.primary else scheme.onSurface
val indicatorColor = if (isSelected) scheme.primary else Color.Transparent
Column(
modifier = modifier
.onFocusChanged { state ->
if (state.isFocused) onFocused()
}
.clickable(onClick = onClick)
) {
TvText(
text = name,
color = color,
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(indicatorColor)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TvEpisodeCarousel(
episodes: List<Episode>,
onPlayEpisode: (Episode) -> Unit,
modifier: Modifier = Modifier,
focusedEpisodeId: UUID? = null
focusedEpisodeId: UUID? = null,
requestFocus: Boolean = false,
onFocusRequested: () -> Unit = {},
upFocusRequester: FocusRequester? = null
) {
val listState = rememberLazyListState()
val focusedEpisodeFocusRequester = remember { FocusRequester() }
val rowScopedFocusRequester = remember { FocusRequester() }
var rowFocusedEpisodeId by remember { mutableStateOf<UUID?>(null) }
var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) }
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
episodes.indexOfFirst { it.id == id }
} ?: -1
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }
val targetEpisodeIndex = when {
focusedEpisodeIndex >= 0 -> focusedEpisodeIndex
firstUnwatchedIndex >= 0 -> firstUnwatchedIndex
episodes.isNotEmpty() -> 0
else -> -1
}
val targetEpisodeId = episodes.getOrNull(targetEpisodeIndex)?.id
LaunchedEffect(episodes, focusedEpisodeId) {
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
episodes.indexOfFirst { it.id == id }
} ?: -1
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
LaunchedEffect(episodes, targetEpisodeId) {
if (episodes.none { it.id == rowFocusedEpisodeId }) {
rowFocusedEpisodeId = targetEpisodeId
}
if (targetIndex != 0) {
listState.scrollToItem(targetIndex)
if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetEpisodeIndex)
} else {
listState.scrollToItem(0)
}
}
if (focusedEpisodeIndex >= 0 && requestedFocusEpisodeId != focusedEpisodeId) {
LaunchedEffect(requestFocus, targetEpisodeId) {
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
rowFocusedEpisodeId = targetEpisodeId
if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetEpisodeIndex)
} else {
listState.scrollToItem(0)
}
withFrameNanos { }
focusedEpisodeFocusRequester.requestFocus()
requestedFocusEpisodeId = focusedEpisodeId
rowScopedFocusRequester.requestFocus()
requestedFocusEpisodeId = targetEpisodeId
onFocusRequested()
}
}
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
state = listState,
modifier = modifier,
modifier = modifier
.focusRestorer(rowScopedFocusRequester)
.focusGroup(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -178,13 +247,27 @@ internal fun TvEpisodeCarousel(
TvEpisodeCard(
episode = episode,
onPlayEpisode = { onPlayEpisode(episode) },
modifier = if (episode.id == focusedEpisodeId) {
Modifier
.focusRequester(focusedEpisodeFocusRequester)
.testTag(SeriesNextUpEpisodeCardTag)
} else {
Modifier
}
onFocused = { rowFocusedEpisodeId = episode.id },
modifier = Modifier
.then(
if (episode.id == rowFocusedEpisodeId) {
Modifier.focusRequester(rowScopedFocusRequester)
} else {
Modifier
}
)
.then(
upFocusRequester?.let { requester ->
Modifier.focusProperties { up = requester }
} ?: Modifier
)
.then(
if (episode.id == targetEpisodeId) {
Modifier.testTag(SeriesNextUpEpisodeCardTag)
} else {
Modifier
}
)
)
}
}
@@ -239,6 +322,7 @@ internal fun TvSeriesHeroSection(
private fun TvEpisodeCard(
episode: Episode,
onPlayEpisode: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -250,7 +334,10 @@ private fun TvEpisodeCard(
modifier = modifier
.width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged { isFocused = it.isFocused }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused()
}
.clickable { onPlayEpisode() },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin
import android.content.pm.ApplicationInfo
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -51,6 +52,7 @@ import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.theme.AppTheme
@@ -146,6 +148,7 @@ class PurefinActivity : ComponentActivity() {
navigationManager: NavigationManager
) {
var sessionLoaded by remember { mutableStateOf(false) }
var showExitDialog by remember { mutableStateOf(false) }
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
LaunchedEffect(Unit) {
@@ -155,11 +158,11 @@ class PurefinActivity : ComponentActivity() {
}
if (!sessionLoaded) {
BackHandler {
showExitDialog = true
}
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
return
}
if (isLoggedIn) {
} else if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
@@ -171,7 +174,13 @@ class PurefinActivity : ComponentActivity() {
LaunchedEffect(navigationManager, backStack) {
navigationManager.commands.collect { command ->
when (command) {
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
NavigationCommand.Pop -> {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
showExitDialog = true
}
}
is NavigationCommand.Navigate -> backStack.add(command.route)
is NavigationCommand.ReplaceAll -> {
backStack.clear()
@@ -181,6 +190,10 @@ class PurefinActivity : ComponentActivity() {
}
}
BackHandler(enabled = backStack.size == 1) {
showExitDialog = true
}
SharedTransitionLayout {
CompositionLocalProvider(
LocalNavigationManager provides navigationManager,
@@ -193,7 +206,13 @@ class PurefinActivity : ComponentActivity() {
) {
NavDisplay(
backStack = backStack,
onBack = { navigationManager.pop() },
onBack = {
if (backStack.size > 1) {
navigationManager.pop()
} else {
showExitDialog = true
}
},
modifier = Modifier
.fillMaxSize()
.background(backgroundDark)
@@ -232,9 +251,19 @@ class PurefinActivity : ComponentActivity() {
}
}
} else {
BackHandler {
showExitDialog = true
}
LoginScreen(
modifier = Modifier.semantics { testTagsAsResourceId = true }
)
}
if (showExitDialog) {
ExitAppDialog(
onCloseConfirmed = { finishAndRemoveTask() },
onDismiss = { showExitDialog = false }
)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -17,6 +18,8 @@ import hu.bbara.purefin.ui.theme.AppTheme
@AndroidEntryPoint
class PlayerActivity : ComponentActivity() {
private val viewModel: PlayerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -43,6 +46,18 @@ class PlayerActivity : ComponentActivity() {
}
}
override fun onPause() {
super.onPause()
if (!isChangingConfigurations) {
viewModel.pausePlayback()
}
}
override fun onResume() {
super.onResume()
enterImmersiveMode()
}
private fun enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {

View File

@@ -1,6 +1,9 @@
package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -18,11 +21,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -32,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -41,6 +47,7 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MediaImageCard(
imageUrl: String?,
@@ -60,13 +67,19 @@ fun MediaImageCard(
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(shapeSize)
var expanded by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
Card(
onClick = onClick,
shape = shape,
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.fillMaxWidth()
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = if (popupActions.isNotEmpty()) {
{ showBottomSheet = true }
} else null
)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
@@ -115,29 +128,39 @@ fun MediaImageCard(
}
}
if (popupActions.isNotEmpty()) {
Box() {
IconButton(
onClick = { expanded = !expanded },
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "More actions"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
popupActions.forEach { action ->
DropdownMenuItem(
text = { Text(text = action.name) },
onClick = action.onClick
)
}
}
IconButton(
onClick = { showBottomSheet = true },
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "More actions"
)
}
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
) {
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
}
)
}
}
}
}
}

View File

@@ -5,93 +5,389 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
data class MediaDetailScaffoldUiModel(
val imageUrl: String,
val title: String,
val imageContentDescription: String? = null,
val titleFontSize: TextUnit = 32.sp,
val titleLineHeight: TextUnit = 38.sp,
val subtitle: String? = null,
val subtitleFontSize: TextUnit = 14.sp,
val subtitleFontWeight: FontWeight = FontWeight.Medium,
val metadataItems: List<String> = emptyList(),
val highlightedMetadataItem: String? = null,
val actions: MediaDetailActionsUiModel? = null,
val synopsis: MediaDetailSynopsisUiModel? = null,
val playbackSettings: MediaDetailPlaybackSettingsUiModel? = null,
val cast: MediaDetailCastUiModel? = null,
)
data class MediaDetailActionsUiModel(
val primaryAction: MediaDetailPrimaryActionUiModel,
val secondaryActions: List<MediaDetailSecondaryActionUiModel> = emptyList(),
val dividerThickness: Dp? = null,
)
data class MediaDetailPrimaryActionUiModel(
val text: String,
val progress: Float,
val onClick: () -> Unit,
val testTag: String? = null,
)
sealed interface MediaDetailSecondaryActionUiModel {
val testTag: String?
data class Download(
val downloadState: DownloadState,
val onClick: () -> Unit,
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
data class Icon(
val icon: ImageVector,
val onClick: () -> Unit = {},
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
data class MarkAsWatched(
val watched: Boolean,
val onClick: () -> Unit,
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
}
data class MediaDetailSynopsisUiModel(
val text: String,
val bodyColor: Color? = null,
val bodyFontSize: TextUnit = 15.sp,
val bodyLineHeight: TextUnit? = 22.sp,
val titleSpacing: Dp = 12.dp,
)
data class MediaDetailPlaybackSettingsUiModel(
val audioTrack: String,
val subtitles: String,
)
data class MediaDetailCastUiModel(
val members: List<CastMember>,
val title: String = "Cast",
val topSpacing: Dp = 0.dp,
val cardWidth: Dp = 96.dp,
val nameFontSize: TextUnit = 12.sp,
val roleFontSize: TextUnit = 10.sp,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDetailScaffold(
imageUrl: String,
uiModel: MediaDetailScaffoldUiModel,
modifier: Modifier = Modifier,
imageHeight: Dp = 320.dp,
contentOverlap: Dp = 24.dp,
contentSpacing: Dp = 16.dp,
topBar: @Composable () -> Unit = {},
heroContent: @Composable ColumnScope.() -> Unit,
content: @Composable ColumnScope.() -> Unit
topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {},
content: @Composable ColumnScope.(Modifier) -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Box(
Scaffold(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
Box(
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = scheme.background,
topBar = {
if (!isHomeMediaTransitionActive) {
topBar(topBarScrollBehavior)
}
}
) { innerPadding ->
Column(
modifier = Modifier
.homeMediaSharedBoundsDestination()
.fillMaxWidth()
.height(imageHeight)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = innerPadding.calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(contentSpacing)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
scheme.background.copy(alpha = 0.5f),
scheme.background
.homeMediaSharedBoundsDestination()
.fillMaxWidth()
.height(imageHeight)
) {
PurefinAsyncImage(
model = uiModel.imageUrl,
contentDescription = uiModel.imageContentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
scheme.background.copy(alpha = 0.5f),
scheme.background
)
)
)
)
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent,
topBar = {
if (!isHomeMediaTransitionActive) {
topBar()
}
)
}
) { innerPadding ->
Column(
if (!isHomeMediaTransitionActive) {
val contentModifier = Modifier.padding(horizontal = 16.dp)
MediaDetailScaffoldContent(
uiModel = uiModel,
modifier = contentModifier
)
content(contentModifier)
}
}
}
}
@Composable
private fun ColumnScope.MediaDetailScaffoldContent(
uiModel: MediaDetailScaffoldUiModel,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = uiModel.title,
color = scheme.onBackground,
fontSize = uiModel.titleFontSize,
fontWeight = FontWeight.Bold,
lineHeight = uiModel.titleLineHeight,
modifier = modifier
)
uiModel.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Text(
text = subtitle,
color = scheme.onBackground,
fontSize = uiModel.subtitleFontSize,
fontWeight = uiModel.subtitleFontWeight,
modifier = modifier
)
}
if (uiModel.metadataItems.any { it.isNotBlank() }) {
MediaMetadataFlowRow(
items = uiModel.metadataItems,
highlightedItem = uiModel.highlightedMetadataItem,
modifier = modifier
)
}
uiModel.actions?.let { actions ->
MediaDetailActions(
uiModel = actions,
modifier = modifier
)
}
uiModel.synopsis?.takeIf { it.text.isNotBlank() }?.let { synopsis ->
MediaSynopsis(
synopsis = synopsis.text,
bodyColor = synopsis.bodyColor ?: scheme.onSurfaceVariant,
bodyFontSize = synopsis.bodyFontSize,
bodyLineHeight = synopsis.bodyLineHeight,
titleSpacing = synopsis.titleSpacing,
modifier = modifier
)
}
uiModel.playbackSettings?.let { playbackSettings ->
MediaPlaybackSettings(
backgroundColor = scheme.surface,
foregroundColor = scheme.onSurface,
audioTrack = playbackSettings.audioTrack,
subtitles = playbackSettings.subtitles,
modifier = modifier
)
}
uiModel.cast?.takeIf { it.members.isNotEmpty() }?.let { cast ->
if (cast.topSpacing > 0.dp) {
Spacer(modifier = Modifier.height(cast.topSpacing))
}
Text(
text = cast.title,
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
Spacer(modifier = modifier.height(12.dp))
MediaDetailCastRow(
cast = cast,
modifier = modifier
)
}
}
@Composable
private fun MediaDetailCastRow(
cast: MediaDetailCastUiModel,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast.members) { member ->
Column(modifier = Modifier.width(cast.cardWidth)) {
PurefinAsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surfaceVariant),
contentScale = ContentScale.Crop,
fallbackIcon = Icons.Outlined.Person
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = scheme.onBackground,
fontSize = cast.nameFontSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = mutedStrong,
fontSize = cast.roleFontSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun MediaDetailActions(
uiModel: MediaDetailActionsUiModel,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
MediaResumeButton(
text = uiModel.primaryAction.text,
progress = uiModel.primaryAction.progress,
onClick = uiModel.primaryAction.onClick,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.optionalTestTag(uiModel.primaryAction.testTag)
)
uiModel.dividerThickness?.let { dividerThickness ->
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = dividerThickness,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = innerPadding.calculateBottomPadding())
.padding(horizontal = 16.dp)
.padding(top = imageHeight - contentOverlap),
verticalArrangement = Arrangement.spacedBy(contentSpacing)
) {
if (!isHomeMediaTransitionActive) {
heroContent()
content()
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
} ?: Spacer(modifier = Modifier.width(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
uiModel.secondaryActions.forEach { action ->
when (action) {
is MediaDetailSecondaryActionUiModel.Download -> {
DownloadActionButton(
downloadState = action.downloadState,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
is MediaDetailSecondaryActionUiModel.Icon -> {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = action.icon,
height = 48.dp,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
is MediaDetailSecondaryActionUiModel.MarkAsWatched -> {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = if (action.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
icon = if (action.watched) {
Icons.Outlined.CheckCircle
} else {
Icons.Outlined.RadioButtonUnchecked
},
height = 48.dp,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
}
}
}
}
}
private fun Modifier.optionalTestTag(testTag: String?): Modifier =
if (testTag == null) this else then(Modifier.testTag(testTag))

View File

@@ -0,0 +1,33 @@
package hu.bbara.purefin.ui.common.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
fun rememberNotificationPermissionGate(): (action: () -> Unit) -> Unit {
val pendingAction = remember { mutableStateOf<(() -> Unit)?>(null) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) {
// Proceed regardless — notification permission is nice-to-have for downloads.
val action = pendingAction.value
pendingAction.value = null
action?.invoke()
}
return remember(launcher) {
{ action ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pendingAction.value = action
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
action()
}
}
}
}

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -12,11 +13,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@@ -45,6 +46,7 @@ fun AppScreen(
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current
@@ -132,12 +134,18 @@ fun AppScreen(
DownloadsScreen(
selectedTab = selectedTab,
isOnline = isOnline,
isCheckingConnection = isCheckingConnection,
onCheckConnection = viewModel::checkConnection,
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
}
}
BackHandler(enabled = backStack.size == 1) {
navigationManager.pop()
}
NavDisplay(
backStack = backStack,
onBack = {

View File

@@ -2,17 +2,23 @@ package hu.bbara.purefin.ui.screen.download
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsScreen(
selectedTab: Int,
isOnline: Boolean,
isCheckingConnection: Boolean,
onCheckConnection: () -> Unit,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -20,6 +26,19 @@ fun DownloadsScreen(
modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
DefaultTopBar(
rightActions = {
if (!isOnline) {
DefaultTopBarTextButton(
text = if (isCheckingConnection) "Checking…" else "Check connection",
onClick = onCheckConnection,
enabled = !isCheckingConnection
)
}
}
)
},
bottomBar = {
AppBottomBar(
selectedTab = selectedTab,

View File

@@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -24,8 +27,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
import hu.bbara.purefin.ui.common.card.PosterCard
@Composable
fun DownloadsContent(
@@ -101,14 +104,17 @@ fun DownloadsContent(
}
}
}
items(downloads.value, key = { item -> item.id }) { item ->
PosterCard(
item = item,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}")
)
items(downloads.value, key = { item -> item.media.id }) { item ->
Column(
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
) {
PosterCard(
item = item.media,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
)
}
}
}
}

View File

@@ -1,20 +1,16 @@
package hu.bbara.purefin.ui.screen.episode
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
@@ -24,9 +20,21 @@ import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeWatchedButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -48,19 +56,13 @@ fun EpisodeScreen(
val episode = viewModel.episode.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val requestNotificationPermission = rememberNotificationPermissionGate()
val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& downloadState.value is DownloadState.NotDownloaded
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
if (downloadState.value is DownloadState.NotDownloaded) {
requestNotificationPermission {
viewModel.onDownloadClick()
}
} else {
viewModel.onDownloadClick()
}
@@ -82,10 +84,12 @@ fun EpisodeScreen(
downloadState = downloadState.value,
onBack = viewModel::onBack,
onDownloadClick = onDownloadClick,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EpisodeScreenInternal(
episode: Episode,
@@ -93,59 +97,79 @@ private fun EpisodeScreenInternal(
downloadState: DownloadState,
onBack: () -> Unit,
onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
uiModel = episode.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
),
modifier = modifier,
topBar = {
topBar = { scrollBehavior ->
EpisodeTopBar(
shortcut = topBarShortcut,
onBack = onBack,
scrollBehavior = scrollBehavior
)
},
heroContent = {
EpisodeHeroContent(episode = episode)
}
) {
EpisodeDetails(
episode = episode,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
)
)
}
private fun Episode.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title,
subtitle = "Episode $index",
metadataItems = listOf(releaseDate, rating, runtime, format),
highlightedMetadataItem = format,
actions = MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(progress, watched),
progress = mediaPlaybackProgress(progress),
onClick = onPlayClick,
testTag = EpisodePlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = watched,
onClick = { onMarkAsWatched(!watched) },
testTag = EpisodeWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState,
onClick = onDownloadClick,
testTag = EpisodeDownloadButtonTag
)
),
dividerThickness = 2.dp
),
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
playbackSettings = MediaDetailPlaybackSettingsUiModel(
audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(members = cast)
} else {
null
}
}
@Composable
private fun EpisodeHeroContent(
episode: Episode,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp
)
Text(
text = "Episode ${episode.index}",
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
EpisodeMetaChips(episode = episode)
}
@Composable
private fun EpisodeMetaChips(episode: Episode) {
MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
highlightedItem = episode.format
)
}
)
@Preview(showBackground = true)
@Composable

View File

@@ -1,44 +1,26 @@
package hu.bbara.purefin.ui.screen.episode.components
import android.content.Intent
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
internal sealed interface EpisodeTopBarShortcut {
@@ -50,10 +32,12 @@ internal sealed interface EpisodeTopBarShortcut {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun EpisodeTopBar(
shortcut: EpisodeTopBarShortcut?,
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
DefaultTopBar(
leftActions = {
@@ -96,84 +80,12 @@ internal fun EpisodeTopBar(
contentDescription = "More",
onClick = { })
},
withIcon = false
withIcon = false,
scrollBehavior = scrollBehavior
)
}
@Composable
internal fun EpisodeDetails(
episode: Episode,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
MediaSynopsis(
synopsis = episode.synopsis
)
Row() {
MediaResumeButton(
text = mediaPlayButtonText(episode.progress, episode.watched),
progress = mediaPlaybackProgress(episode.progress),
onClick = playAction,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(EpisodePlayButtonTag)
)
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = 2.dp,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = 48.dp,
modifier = Modifier.testTag(EpisodeDownloadButtonTag),
onClick = onDownloadClick
)
}
}
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
//TODO fix it
audioTrack = "ENG",
subtitles = "ENG"
)
if (episode.cast.isNotEmpty()) {
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
//TODO use it
// MediaCastRow(
// cast = episode.cast
// )
}
}
internal const val EpisodeSeriesButtonTag = "episode-series-button"
internal const val EpisodePlayButtonTag = "episode-play-button"
internal const val EpisodeDownloadButtonTag = "episode-download-button"
internal const val EpisodeWatchedButtonTag = "episode-watched-button"

View File

@@ -2,19 +2,23 @@ package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
libraries: List<LibraryUiModel>,
@@ -39,9 +43,12 @@ fun HomeScreen(
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold(
modifier = modifier
.fillMaxSize(),
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
@@ -51,7 +58,8 @@ fun HomeScreen(
onCheckForUpdates = onCheckForUpdates,
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = onSettingsClick,
onLogoutClick = onLogoutClick
onLogoutClick = onLogoutClick,
scrollBehavior = topBarScrollBehavior
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },

View File

@@ -5,21 +5,26 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -32,58 +37,58 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.common.image.PurefinLogo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun rememberDefaultTopBarScrollBehavior(): TopAppBarScrollBehavior =
TopAppBarDefaults.pinnedScrollBehavior()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DefaultTopBar(
leftActions: (@Composable RowScope.() -> Unit)? = null,
rightActions: (@Composable RowScope.() -> Unit)? = null,
withIcon: Boolean = true
withIcon: Boolean = true,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
val scheme = MaterialTheme.colorScheme
Surface(
color = Color.Transparent,
contentColor = scheme.onSurface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.statusBarsPadding()
.height(84.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
TopAppBar(
title = {
if (withIcon) {
PurefinLogo(
modifier = Modifier.size(56.dp),
)
}
},
navigationIcon = {
if (leftActions != null) {
Row(
modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
content = leftActions
)
}
if (withIcon) {
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = when {
leftActions == null && rightActions != null -> Arrangement.Start
leftActions != null && rightActions == null -> Arrangement.Center
else -> Arrangement.Center
}
) {
PurefinLogo(
modifier = Modifier.size(56.dp),
)
}
}
},
actions = {
if (rightActions != null) {
Row(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
content = rightActions
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = scheme.background,
navigationIconContentColor = scheme.onSurface,
titleContentColor = scheme.onSurface,
actionIconContentColor = scheme.onSurface
),
scrollBehavior = scrollBehavior
)
}
@Composable
@@ -114,6 +119,37 @@ fun DefaultTopBarIconButton(
}
}
@Composable
fun DefaultTopBarTextButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val scheme = MaterialTheme.colorScheme
TextButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = ButtonDefaults.textButtonColors(
containerColor = scheme.surface,
contentColor = scheme.onSurface,
disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface
),
contentPadding = PaddingValues(horizontal = 18.dp),
modifier = modifier
.height(50.dp)
.clip(CircleShape),
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DefaultTopBarSearchButton(

View File

@@ -121,11 +121,12 @@ fun HomeContent(
if (nextUp.isNotEmpty()) {
item(key = "next-up") {
NextUpSection(
items = nextUp,
onMediaSelected = onMediaSelected,
modifier = Modifier.testTag(HomeNextUpSectionTag)
)
NextUpSection(
items = nextUp,
onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag(HomeNextUpSectionTag)
)
}
}
@@ -138,6 +139,7 @@ fun HomeContent(
items = libraryContent[library.id].orEmpty(),
onLibrarySelected = onLibrarySelected,
onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
)
}

View File

@@ -4,13 +4,16 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTopBar(
onSearchClick: () -> Unit,
@@ -19,10 +22,12 @@ fun HomeTopBar(
isCheckingForUpdates: Boolean,
onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
var isProfileMenuExpanded by remember { mutableStateOf(false) }
DefaultTopBar(
scrollBehavior = scrollBehavior,
rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick)
DefaultTopBarIconButton(

View File

@@ -10,28 +10,41 @@ import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel
import androidx.compose.runtime.remember
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaAction
@Composable
internal fun HomeBrowseCard(
uiModel: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
val popupActions = remember(uiModel, onMarkAsWatched) {
buildList {
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
}
}
}
MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick,
popupActions = popupActions,
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
imageAspectRatio = 15f / 16f,
modifier = modifier.width(188.dp)

View File

@@ -25,6 +25,7 @@ fun LibraryPosterSection(
items: List<MediaUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) return
@@ -61,6 +62,7 @@ fun LibraryPosterSection(
mediaId = item.id
),
onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
)
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -13,24 +14,39 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.SeriesUiModel
@Composable
internal fun NextUpCard(
uiModel: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
val popupActions = remember(uiModel, onMarkAsWatched) {
buildList {
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
}
}
}
MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick,
popupActions = popupActions,
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 24.dp,
titleStyle = MaterialTheme.typography.titleSmall,

View File

@@ -22,6 +22,7 @@ import hu.bbara.purefin.core.model.MediaUiModel
fun NextUpSection(
items: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -51,6 +52,7 @@ fun NextUpSection(
uiModel = item,
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
)
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -38,14 +39,20 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
@@ -64,6 +71,7 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.theme.AppTheme
import java.util.UUID
@@ -126,7 +134,7 @@ fun SearchScreen(
)
}
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun SearchScreenContent(
query: String,
@@ -142,13 +150,17 @@ private fun SearchScreenContent(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold(
topBar = { SearchHeader(
onBack = onBack
onBack = onBack,
scrollBehavior = topBarScrollBehavior
) },
modifier = modifier
.fillMaxSize()
.testTag(SearchScreenTag)
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.background(scheme.background)
) { innerPadding ->
Column(
@@ -216,10 +228,13 @@ private fun SearchResults(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchHeader(
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
DefaultTopBar(
scrollBehavior = scrollBehavior,
leftActions = {
DefaultTopBarIconButton(
imageVector = Icons.Outlined.ArrowBack,
@@ -239,6 +254,11 @@ private fun SearchField(
) {
val scheme = MaterialTheme.colorScheme
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
TextField(
value = query,
@@ -291,6 +311,7 @@ private fun SearchField(
),
modifier = modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.testTag(SearchQueryFieldTag)
.height(68.dp)
)

View File

@@ -2,15 +2,19 @@ package hu.bbara.purefin.ui.screen.libraries
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibrariesScreen(
items: List<LibraryUiModel>,
@@ -21,13 +25,18 @@ fun LibrariesScreen(
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold(
modifier = modifier.fillMaxSize(),
modifier = modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
LibrariesTopBar(
onSearchClick = onSearchClick,
scrollBehavior = topBarScrollBehavior
)
},
bottomBar = {

View File

@@ -1,14 +1,19 @@
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarSearchButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibrariesTopBar(
onSearchClick: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
DefaultTopBar(
scrollBehavior = scrollBehavior,
rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick)
}

View File

@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -28,8 +31,11 @@ import hu.bbara.purefin.core.model.SeriesUiModel
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(
library: LibraryDto,
@@ -41,14 +47,18 @@ fun LibraryScreen(
}
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold(
modifier = modifier.testTag(LibraryScreenTag),
modifier = modifier
.testTag(LibraryScreenTag)
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
LibraryTopBar(
onBack = { viewModel.onBack() }
onBack = { viewModel.onBack() },
scrollBehavior = topBarScrollBehavior
)
}
) { innerPadding ->
@@ -58,7 +68,10 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background)
.padding(innerPadding)
) {
LibraryPosterGrid(libraryItems = libraryItems.value)
LibraryPosterGrid(
libraryItems = libraryItems.value,
onMarkAsWatched = viewModel::markAsWatched,
)
}
}
}
@@ -67,6 +80,7 @@ fun LibraryScreen(
internal fun LibraryPosterGrid(
libraryItems: List<MediaUiModel>,
modifier: Modifier = Modifier,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
viewModel: LibraryViewModel = hiltViewModel()
) {
BoxWithConstraints(
@@ -83,6 +97,15 @@ internal fun LibraryPosterGrid(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(libraryItems, key = { item -> item.id }) { item ->
val popupActions = remember {
buildList {
if (item is MovieUiModel || item is EpisodeUiModel || item is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(item, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(item, false) })
}
}
}
MediaImageCard(
imageUrl = item.primaryImageUrl,
title = item.primaryText,
@@ -94,6 +117,7 @@ internal fun LibraryPosterGrid(
is EpisodeUiModel -> Unit
}
},
popupActions = popupActions,
modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}")
) {
when (item) {

View File

@@ -3,15 +3,20 @@ package hu.bbara.purefin.ui.screen.library.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryTopBar(
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
DefaultTopBar(
scrollBehavior = scrollBehavior,
leftActions = {
DefaultTopBarIconButton(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,

View File

@@ -1,19 +1,16 @@
package hu.bbara.purefin.ui.screen.movie
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
@@ -21,9 +18,21 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.screen.movie.components.MovieWatchedButtonTag
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme
@@ -41,19 +50,13 @@ fun MovieScreen(
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val requestNotificationPermission = rememberNotificationPermissionGate()
val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& downloadState.value is DownloadState.NotDownloaded
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
if (downloadState.value is DownloadState.NotDownloaded) {
requestNotificationPermission {
viewModel.onDownloadClick()
}
} else {
viewModel.onDownloadClick()
}
@@ -65,6 +68,7 @@ fun MovieScreen(
downloadState = downloadState.value,
onDownloadClick = onDownloadClick,
onBack = viewModel::onBack,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier
)
} else {
@@ -72,52 +76,87 @@ fun MovieScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MovieScreenInternal(
movie: Movie,
downloadState: DownloadState = DownloadState.NotDownloaded,
onDownloadClick: () -> Unit = {},
onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
modifier = modifier,
topBar = {
MovieTopBar(onBack = onBack)
},
heroContent = {
MovieHeroContent(movie = movie)
val context = LocalContext.current
val playAction = remember(movie.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movie.id.toString())
context.startActivity(intent)
}
) {
MovieDetails(
movie = movie,
}
MediaDetailScaffold(
uiModel = movie.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
),
modifier = modifier,
topBar = { scrollBehavior ->
MovieTopBar(
onBack = onBack,
scrollBehavior = scrollBehavior
)
},
)
}
private fun Movie.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title,
metadataItems = listOf(year, rating, runtime, format),
highlightedMetadataItem = format,
actions = MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(progress, watched),
progress = mediaPlaybackProgress(progress),
onClick = onPlayClick,
testTag = MoviePlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = watched,
onClick = { onMarkAsWatched(!watched) },
testTag = MovieWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState,
onClick = onDownloadClick,
testTag = MovieDownloadButtonTag
)
),
dividerThickness = 4.dp
),
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
playbackSettings = MediaDetailPlaybackSettingsUiModel(
audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
members = cast,
topSpacing = 24.dp
)
} else {
null
}
}
@Composable
private fun MovieHeroContent(
movie: Movie,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp
)
// Spacer(modifier = Modifier.height(8.dp))
MediaMetadataFlowRow(
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
highlightedItem = movie.format
)
}
)
@Preview(showBackground = true)
@Composable

View File

@@ -1,45 +1,20 @@
package hu.bbara.purefin.ui.screen.movie.components
import android.content.Intent
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MovieTopBar(
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
DefaultTopBar(
leftActions = {
@@ -56,84 +31,11 @@ internal fun MovieTopBar(
contentDescription = "More",
onClick = { })
},
withIcon = false
withIcon = false,
scrollBehavior = scrollBehavior
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun MovieDetails(
movie: Movie,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val playAction = remember(movie.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movie.id.toString())
context.startActivity(intent)
}
}
MediaSynopsis(
synopsis = movie.synopsis
)
Row() {
MediaResumeButton(
text = mediaPlayButtonText(movie.progress, movie.watched),
progress = mediaPlaybackProgress(movie.progress),
onClick = playAction,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(MoviePlayButtonTag)
)
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = 4.dp,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = 48.dp,
modifier = Modifier.testTag(MovieDownloadButtonTag),
onClick = onDownloadClick
)
}
}
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
audioTrack = "ENG",
subtitles = "ENG"
)
if (movie.cast.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
//TODO fix
// MediaCastRow(
// cast = movie.cast,
// )
}
}
internal const val MoviePlayButtonTag = "movie-play-button"
internal const val MovieDownloadButtonTag = "movie-download-button"
internal const val MovieWatchedButtonTag = "movie-watched-button"

View File

@@ -12,6 +12,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,12 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,18 +36,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
@@ -59,13 +57,16 @@ import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
private const val WIDE_VIDEO_ASPECT_RATIO = 16f / 9f
/** Small negative band below zero that represents adaptive/auto brightness mode. */
private const val AUTO_BRIGHTNESS_SENTINEL = -0.1f
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class)
@Composable
@@ -73,8 +74,13 @@ fun PlayerScreen(
viewModel: PlayerViewModel,
onBack: () -> Unit
) {
val scope = rememberCoroutineScope()
var hideControlsJob: Job? by remember { mutableStateOf(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
var controlsVisible by remember { mutableStateOf(true) }
var controlsHasPopupOpen by remember { mutableStateOf(false) }
val context = LocalContext.current
val activity = context as? Activity
@@ -85,40 +91,45 @@ fun PlayerScreen(
var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
var videoAspectRatio by remember { mutableStateOf(calculateVideoAspectRatio(viewModel.player.videoSize)) }
val overlayController = rememberPersistentOverlayController()
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
viewModel.pausePlayback()
}
DisposableEffect(viewModel.player) {
val listener = object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
videoAspectRatio = calculateVideoAspectRatio(videoSize)
}
}
viewModel.player.addListener(listener)
onDispose {
viewModel.player.removeListener(listener)
}
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) {
showQueuePanel = false
}
}
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
val playerResizeMode = playerResizeMode(videoAspectRatio)
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null && uiState.activeSkippableSegmentType == SegmentType.INTRO
val showNextEpisodeOverlay = !controlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& ((uiState.durationMs - uiState.positionMs) <= 60_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
&& !uiState.isEnded
fun toggleControlsVisibility() {
controlsVisible = !controlsVisible
}
fun hideControls() {
controlsVisible = false
}
fun hideControlsWithTimeout() {
hideControlsJob?.cancel()
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
hideControlsJob = scope.launch {
delay(CONTROLS_AUTO_HIDE_MS)
hideControls()
}
}
}
fun onScreenTap() {
toggleControlsVisibility()
hideControlsWithTimeout()
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -128,29 +139,29 @@ fun PlayerScreen(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
resizeMode = playerResizeMode
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
}
},
update = {
it.player = viewModel.player
it.resizeMode = playerResizeMode
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
onRelease = { playerView ->
playerView.player = null
},
modifier = Modifier
.fillMaxSize()
.fillMaxHeight()
.align(Alignment.Center)
)
PlayerGesturesLayer(
modifier = Modifier.align(Alignment.BottomCenter),
onTap = { viewModel.toggleControlsVisibility() },
controlsVisible = controlsVisible,
onTap = ::onScreenTap,
onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta ->
val diff = (-delta / 800f)
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_SENTINEL, 1f)
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
applyBrightness(activity, brightness)
},
onVerticalDragRight = { delta ->
@@ -175,7 +186,7 @@ fun PlayerScreen(
onHorizontalDragSeekTo = {
viewModel.seekTo(it)
},
currentPositionProvider = { uiState.positionMs }
currentPositionProvider = { uiState.positionMs },
)
EmptyValueTimedVisibility(
@@ -193,7 +204,7 @@ fun PlayerScreen(
value = horizontalSeekPreviewPositionMs,
hideAfterMillis = 1_000
) { previewPositionMs ->
if (!playerControlsVisible) {
if (controlsVisible) {
HiddenSeekTimeline(
positionMs = previewPositionMs,
durationMs = uiState.durationMs,
@@ -216,11 +227,17 @@ fun PlayerScreen(
.padding(start = 20.dp)
) { currentBrightness ->
PlayerAdjustmentIndicator(
modifier = Modifier
.align(Alignment.Center),
modifier = Modifier.align(Alignment.Center),
icon = Icons.Outlined.BrightnessMedium,
contentDescription = "Brightness",
value = currentBrightness
value = currentBrightness,
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
"Auto"
} else if (currentBrightness >= 0f) {
"${(currentBrightness * 100).roundToInt()}%"
} else {
null
}
)
}
@@ -236,19 +253,19 @@ fun PlayerScreen(
.align(Alignment.Center),
icon = Icons.Outlined.VolumeUp,
contentDescription = "Volume",
value = currentVolume
value = currentVolume,
bottomText = "${(currentVolume.coerceIn(0f, 1f) * 100).roundToInt()}%"
)
}
AnimatedVisibility(
visible = playerControlsVisible,
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
PlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
showControls = controlsVisible,
overlayController = overlayController,
onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() },
@@ -259,23 +276,45 @@ fun PlayerScreen(
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onSelectTrack = { viewModel.selectTrack(it) },
onQueueSelected = { viewModel.playQueueItem(it) },
onOpenQueue = { showQueuePanel = true }
)
}
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
visible = showSkipIntroButton,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(
end = 24.dp,
)
) {
SkipSegmentButton(
size = 72.dp,
fontSize = 22,
onClick = { viewModel.skipActiveSegment() }
)
}
AnimatedVisibility(
visible = showNextEpisodeOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 24.dp,
bottom = if (horizontalSeekPreviewPositionMs != null) 96.dp else 24.dp
bottom = 24.dp
)
) {
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
uiState.nextEpisode?.let { nextEpisode ->
NextEpisodeOverlay(
nextEpisode = nextEpisode,
onClick = { viewModel.next() }
)
}
}
PlayerLoadingErrorEndCard(
@@ -313,21 +352,6 @@ fun PlayerScreen(
}
}
@OptIn(UnstableApi::class)
private fun playerResizeMode(videoAspectRatio: Float): Int =
if (videoAspectRatio >= WIDE_VIDEO_ASPECT_RATIO) {
AspectRatioFrameLayout.RESIZE_MODE_ZOOM
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIT
}
private fun calculateVideoAspectRatio(videoSize: VideoSize): Float {
if (videoSize.width <= 0 || videoSize.height <= 0) {
return 0f
}
return videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
}
@Composable
private fun HiddenSeekTimeline(
positionMs: Long,
@@ -402,12 +426,12 @@ private fun formatSeekDelta(deltaMs: Long): String {
private fun readCurrentBrightness(activity: Activity?): Float {
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else AUTO_BRIGHTNESS_SENTINEL
return if (current != null && current >= 0) current else -1f
}
private fun applyBrightness(activity: Activity?, value: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = if (value < 0f) -1f else value
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
activity.window.attributes = params
}

View File

@@ -0,0 +1,106 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
/**
* Overlay button that appears near the end of a media item to let the user
* quickly jump to the next episode. Shows the next episode's artwork and title.
*/
@Composable
fun NextEpisodeOverlay(
nextEpisode: PlaylistElementUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.85f))
.clickable { onClick() },
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// "Up Next" label
Text(
text = "Up Next",
color = scheme.secondary,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, end = 12.dp)
)
Spacer(modifier = Modifier.height(4.dp))
// Thumbnail with play button overlay
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
) {
PurefinAsyncImage(
model = nextEpisode.artworkUrl,
contentDescription = nextEpisode.title,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
)
// Dark gradient overlay for readability
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.1f),
Color.Black.copy(alpha = 0.4f)
)
)
)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Episode title
Text(
text = nextEpisode.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp)
)
}
}
internal const val PlayerNextEpisodeOverlayTag = "player_next_episode_overlay"

View File

@@ -13,6 +13,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -20,7 +22,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun PlayerAdjustmentIndicator(
@@ -28,12 +29,15 @@ fun PlayerAdjustmentIndicator(
icon: ImageVector,
contentDescription: String?,
value: Float,
bottomText: String? = null,
sliderHeight: Dp = 140.dp,
) {
val scheme = MaterialTheme.colorScheme
val isAuto = value < 0f
val percent = if (isAuto) 0 else (value * 100).roundToInt()
val clamped = value.coerceIn(0f, 1f)
val previousBottomText = remember { mutableStateOf("-") }
if (bottomText != null) {
previousBottomText.value = bottomText
}
Box(
modifier = modifier
@@ -69,10 +73,9 @@ fun PlayerAdjustmentIndicator(
)
}
Spacer(modifier = Modifier.height(16.dp))
val label = if (isAuto) "Auto" else "$percent%"
Text(
text = label,
color = if (isAuto) scheme.tertiary else scheme.onSurface,
text = previousBottomText.value,
color = scheme.onSurface,
style = MaterialTheme.typography.titleMedium
)
}

View File

@@ -10,11 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
@@ -26,32 +22,26 @@ import androidx.compose.material.icons.outlined.PlaylistPlay
import androidx.compose.material.icons.outlined.Replay10
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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 hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.ui.common.button.CircularTextButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.PurefinIconButton
@Composable
fun PlayerControlsOverlay(
uiState: PlayerUiState,
showControls: Boolean,
overlayController: PersistentOverlayController,
onBack: () -> Unit,
onPlayPause: () -> Unit,
@@ -62,11 +52,9 @@ fun PlayerControlsOverlay(
onNext: () -> Unit,
onPrevious: () -> Unit,
onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
var scrubbing by remember { mutableStateOf(false) }
Box(
modifier = modifier
@@ -98,11 +86,8 @@ fun PlayerControlsOverlay(
)
BottomSection(
uiState = uiState,
scrubbing = scrubbing,
overlayController = overlayController,
onScrubStart = { scrubbing = true },
onScrub = onSeek,
onScrubFinished = { scrubbing = false },
onNext = onNext,
onPrevious = onPrevious,
onPlayPause = onPlayPause,
@@ -111,7 +96,6 @@ fun PlayerControlsOverlay(
onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment,
onSelectTrack = onSelectTrack,
onQueueSelected = onQueueSelected,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
@@ -163,11 +147,8 @@ private fun TopBar(
@Composable
private fun BottomSection(
uiState: PlayerUiState,
scrubbing: Boolean,
overlayController: PersistentOverlayController,
onScrubStart: () -> Unit,
onScrub: (Long) -> Unit,
onScrubFinished: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onPlayPause: () -> Unit,
@@ -176,7 +157,6 @@ private fun BottomSection(
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
@@ -194,8 +174,6 @@ private fun BottomSection(
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onScrub,
onScrubStarted = onScrubStart,
onScrubFinished = onScrubFinished,
modifier = Modifier.testTag(PlayerSeekBarTag)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -295,32 +273,21 @@ internal const val PlayerSkipSegmentButtonTag = "player_skip_segment_button"
@Composable
fun SkipSegmentButton(
onClick: () -> Unit,
fontSize: Int = 18,
size: Dp = 52.dp,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
FilledTonalButton(
CircularTextButton(
text = "Skip",
textColor = scheme.onSecondary,
fontSize = fontSize,
containerColor = scheme.secondary,
size = size,
onClick = onClick,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(50),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = scheme.secondary.copy(alpha = 0.92f),
contentColor = scheme.onSecondary
),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding
) {
Text(
text = "Skip",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Outlined.SkipNext,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
modifier = modifier
)
}
@Composable

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
@@ -22,6 +23,7 @@ import kotlin.math.abs
@Composable
fun PlayerGesturesLayer(
modifier: Modifier = Modifier,
controlsVisible: Boolean,
onTap: () -> Unit,
onDoubleTapCenter: () -> Unit,
onDoubleTapRight: () -> Unit,
@@ -36,6 +38,7 @@ fun PlayerGesturesLayer(
val horizontalThresholdPx = with(density) { HorizontalSeekGestureHelper.START_THRESHOLD.toPx() }
val directionThresholdPx = with(density) { 20.dp.toPx() }
val dragActive = remember { mutableStateOf(false) }
val currentControlsVisible = rememberUpdatedState(controlsVisible)
Box(
modifier = modifier
@@ -63,6 +66,13 @@ fun PlayerGesturesLayer(
val startX = down.position.x
dragActive.value = false
if (currentControlsVisible.value) {
// Swipe gestures are disabled while the controls overlay is visible.
// Tap gestures (single/double tap) are still handled by the
// pointerInput block above.
return@awaitEachGesture
}
var accumulatedDrag = Offset.Zero
var dragDirection: DragDirection? = null
var accumulatedHorizontalDrag = 0f

View File

@@ -27,8 +27,6 @@ fun PlayerSeekBar(
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onScrubStarted: () -> Unit,
onScrubFinished: () -> Unit,
modifier: Modifier = Modifier
) {
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
onValueChange = { newValue ->
if (!isScrubbing) {
isScrubbing = true
onScrubStarted()
}
sliderPosition = newValue
},
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
isScrubbing = false
onSeek(targetPosition)
onScrubFinished()
},
valueRange = 0f..safeDuration.toFloat(),
colors = SliderDefaults.colors(

View File

@@ -1,42 +1,55 @@
package hu.bbara.purefin.ui.screen.series
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.series.components.DownloadOptionsBottomSheet
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
import hu.bbara.purefin.ui.screen.series.components.SeriesActionButtons
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption
import hu.bbara.purefin.ui.screen.series.components.SeriesMetaChips
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar
import hu.bbara.purefin.ui.screen.series.components.SeriesWatchedButtonTag
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme
import java.util.UUID
@Composable
@@ -50,35 +63,60 @@ fun SeriesScreen(
}
val seriesState = viewModel.series.collectAsStateWithLifecycle()
val requestNotificationPermission = rememberNotificationPermissionGate()
val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
if (seriesData != null) {
LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData)
}
val seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value
val seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value
val isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value
fun canPerformDownloadOption(option: SeriesDownloadOption): Boolean =
option.canPerform(
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled
)
fun performDownloadOption(option: SeriesDownloadOption, selectedSeason: Season) {
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
SeriesDownloadOption.SERIES ->
viewModel.downloadSeries(seriesData)
SeriesDownloadOption.SMART ->
viewModel.enableSmartDownload(seriesData.id)
SeriesDownloadOption.DELETE_SMART ->
viewModel.deleteSmartDownloads(seriesData.id)
}
}
SeriesScreenInternal(
series = seriesData,
seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value,
seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value,
isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { option, selectedSeason ->
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
SeriesDownloadOption.SERIES ->
viewModel.downloadSeries(seriesData)
SeriesDownloadOption.SMART ->
viewModel.enableSmartDownload(seriesData.id)
SeriesDownloadOption.DELETE_SMART ->
viewModel.deleteSmartDownloads(seriesData.id)
if (canPerformDownloadOption(option)) {
if (option.requiresNotificationPermission()) {
requestNotificationPermission {
performDownloadOption(option, selectedSeason)
}
} else {
performDownloadOption(option, selectedSeason)
}
}
},
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome,
onMarkAsWatched = viewModel::markAsWatched,
offline = series.offline,
modifier = modifier
)
@@ -87,6 +125,7 @@ fun SeriesScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SeriesScreenInternal(
series: Series,
@@ -97,211 +136,174 @@ private fun SeriesScreenInternal(
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
offline: Boolean,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first()
fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
}
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) }
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
val selectedSeason =
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched }
?: selectedSeason.episodes.firstOrNull()
LaunchedEffect(series.id, selectedSeason.id) {
onLoadSeasonEpisodes(series.id, selectedSeason.id)
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason?.episodes?.firstOrNull()
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
{
navigationManager.navigate(
Route.EpisodeRoute(
EpisodeDto(
id = episode.id,
seasonId = episode.seasonId,
seriesId = episode.seriesId,
offline = offline,
)
)
)
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
}
LaunchedEffect(selectedSeason.id, selectedSeason.episodes) {
onObserveSeasonDownloadState(selectedSeason.episodes)
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
}
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
selectedSeason?.let { onObserveSeasonDownloadState(it.episodes) }
}
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(series.imageUrlPrefix, ArtworkKind.PRIMARY),
modifier = modifier,
topBar = {
SeriesTopBar(onBack = onBack)
},
heroContent = {
SeriesHeroContent(series = series)
}
) {
SeriesActionButtons(
nextUpEpisode = nextUpEpisode,
seriesDownloadState = seriesDownloadState,
uiModel = series.toMediaDetailScaffoldUiModel(
selectedSeason = selectedSeason,
nextUpEpisode = nextUpEpisode,
onPlayClick = playAction ?: {},
onDownloadClick = { showDownloadDialog = true },
onMarkAsWatched = onMarkAsWatched,
bodyColor = scheme.onSurface
),
modifier = modifier,
topBar = { scrollBehavior ->
SeriesTopBar(
onBack = onBack,
scrollBehavior = scrollBehavior
)
},
) { _modifier ->
if (selectedSeason != null) {
SeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason,
onSelect = { selectedSeasonId = it.id },
modifier = _modifier
)
EpisodeCarousel(
episodes = selectedSeason.episodes,
modifier = _modifier
)
} else {
Text(
text = "Loading seasons...",
color = scheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = _modifier
)
}
}
if (showDownloadDialog && selectedSeason != null) {
DownloadOptionsBottomSheet(
selectedSeasonName = selectedSeason.name,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
offline = offline,
onDownloadOptionSelected = { option ->
onDownloadOptionSelected(option, selectedSeason)
}
)
MediaSynopsis(
synopsis = series.synopsis,
bodyColor = scheme.onSurface,
bodyFontSize = 13.sp,
bodyLineHeight = null,
titleSpacing = 8.dp
)
SeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason,
onSelect = { selectedSeasonId = it.id }
)
EpisodeCarousel(
episodes = selectedSeason.episodes,
)
if (series.cast.isNotEmpty()) {
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = series.cast)
}
}
}
@Composable
private fun SeriesHeroContent(
series: Series,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = series.name,
color = scheme.onBackground,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
lineHeight = 36.sp
)
SeriesMetaChips(series = series)
}
@Preview(showBackground = true)
@Composable
private fun SeriesScreenPreview() {
AppTheme {
SeriesScreenInternal(
series = previewSeries(),
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f),
seasonDownloadState = DownloadState.NotDownloaded,
isSmartDownloadEnabled = true,
onDownloadOptionSelected = { _, _ -> },
onLoadSeasonEpisodes = { _, _ -> },
onObserveSeasonDownloadState = {},
onBack = {},
offline = false,
onDownloadOptionSelected = {
showDownloadDialog = false
onDownloadOptionSelected(it, selectedSeason)
},
onDismiss = { showDownloadDialog = false }
)
}
}
private fun previewSeries(): Series {
val libraryId = UUID.fromString("66666666-6666-6666-6666-666666666666")
val seriesId = UUID.fromString("77777777-7777-7777-7777-777777777777")
val seasonOneId = UUID.fromString("88888888-8888-8888-8888-888888888888")
val seasonTwoId = UUID.fromString("99999999-9999-9999-9999-999999999999")
val seasonOneEpisodes = listOf(
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId,
seasonIndex = 1,
index = 1,
title = "A Fresh Start",
synopsis = "A fractured crew tries to reassemble after a year apart.",
releaseDate = "2024",
rating = "16+",
runtime = "51m",
progress = 100.0,
watched = true,
format = "4K",
imageUrlPrefix = "https://images.unsplash.com/photo-1497032205916-ac775f0649ae",
cast = emptyList()
),
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId,
seasonIndex = 1,
index = 2,
title = "Signals",
synopsis = "Anomalies around the station point to a cover-up.",
releaseDate = "2024",
rating = "16+",
runtime = "48m",
progress = 34.0,
watched = false,
format = "4K",
imageUrlPrefix = "https://images.unsplash.com/photo-1520034475321-cbe63696469a",
cast = emptyList()
)
)
val seasonTwoEpisodes = listOf(
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonTwoId,
seasonIndex = 2,
index = 1,
title = "Return Window",
synopsis = "A high-risk jump changes the rules of the mission.",
releaseDate = "2025",
rating = "16+",
runtime = "54m",
progress = null,
watched = false,
format = "4K",
imageUrlPrefix = "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429",
cast = emptyList()
)
)
return Series(
id = seriesId,
libraryId = libraryId,
name = "Constellation",
synopsis = "When an experiment in orbit goes wrong, the survivors return home to a world that no longer fits their memories.",
year = "2024",
imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
unwatchedEpisodeCount = 2,
seasonCount = 2,
seasons = listOf(
Season(
id = seasonOneId,
seriesId = seriesId,
name = "Season 1",
index = 1,
unwatchedEpisodeCount = 1,
episodeCount = seasonOneEpisodes.size,
episodes = seasonOneEpisodes
private fun Series.toMediaDetailScaffoldUiModel(
selectedSeason: Season?,
nextUpEpisode: Episode?,
onPlayClick: () -> Unit,
onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
bodyColor: Color,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = name,
titleFontSize = 30.sp,
titleLineHeight = 36.sp,
metadataItems = listOf(year, "$seasonCount Seasons"),
actions = selectedSeason?.let {
val seriesWatched = unwatchedEpisodeCount == 0
MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = onPlayClick,
testTag = SeriesPlayButtonTag
),
Season(
id = seasonTwoId,
seriesId = seriesId,
name = "Season 2",
index = 2,
unwatchedEpisodeCount = 1,
episodeCount = seasonTwoEpisodes.size,
episodes = seasonTwoEpisodes
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = seriesWatched,
onClick = { onMarkAsWatched(!seriesWatched) },
testTag = SeriesWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Icon(
icon = Icons.Outlined.Download,
onClick = onDownloadClick,
testTag = SeriesDownloadButtonTag
)
)
),
cast = listOf(
CastMember("Noomi Rapace", "Jo", null),
CastMember("Jonathan Banks", "Henry", null),
CastMember("James D'Arcy", "Magnus", null)
)
)
},
synopsis = MediaDetailSynopsisUiModel(
text = synopsis,
bodyColor = bodyColor,
bodyFontSize = 13.sp,
bodyLineHeight = null,
titleSpacing = 8.dp
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
members = cast,
cardWidth = 84.dp,
nameFontSize = 11.sp,
roleFontSize = 10.sp
)
} else {
null
}
)
private fun SeriesDownloadOption.canPerform(
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
): Boolean = when (this) {
SeriesDownloadOption.SEASON -> seasonDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SERIES -> seriesDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SMART -> !isSmartDownloadEnabled
SeriesDownloadOption.DELETE_SMART -> true
}
private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = when (this) {
SeriesDownloadOption.SEASON,
SeriesDownloadOption.SERIES,
SeriesDownloadOption.SMART -> true
SeriesDownloadOption.DELETE_SMART -> false
}

View File

@@ -1,45 +1,38 @@
package hu.bbara.purefin.ui.screen.series.components
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -48,8 +41,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -60,29 +53,21 @@ import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SeriesTopBar(
onBack: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
DefaultTopBar(
leftActions = {
@@ -97,97 +82,11 @@ internal fun SeriesTopBar(
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
}
},
withIcon = false
withIcon = false,
scrollBehavior = scrollBehavior
)
}
@Composable
internal fun SeriesMetaChips(series: Series) {
MediaMetadataFlowRow(
items = listOf(series.year, "${series.seasonCount} Seasons")
)
}
@Composable
internal fun SeriesActionButtons(
nextUpEpisode: Episode?,
seriesDownloadState: DownloadState,
selectedSeason: Season,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
offline: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
val scheme = MaterialTheme.colorScheme
var showDownloadDialog by remember { mutableStateOf(false) }
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
{
navigationManager.navigate(
Route.EpisodeRoute(
EpisodeDto(
id = episode.id,
seasonId = episode.seasonId,
seriesId = episode.seriesId,
offline = offline,
)
)
)
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
MediaResumeButton(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = playAction ?: {},
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(SeriesPlayButtonTag)
)
Spacer(modifier = Modifier.width(12.dp))
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = Icons.Outlined.Add,
height = 48.dp,
modifier = Modifier.testTag(SeriesAddButtonTag)
)
Spacer(modifier = Modifier.width(12.dp))
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when {
seriesDownloadState is DownloadState.Downloading -> Icons.Outlined.Close
seriesDownloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
else -> Icons.Outlined.Download
},
height = 48.dp,
modifier = Modifier.testTag(SeriesDownloadButtonTag),
onClick = { showDownloadDialog = true }
)
}
if (showDownloadDialog) {
DownloadOptionsDialog(
selectedSeasonName = selectedSeason.name,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = {
showDownloadDialog = false
onDownloadOptionSelected(it)
},
onDismiss = { showDownloadDialog = false }
)
}
}
internal enum class SeriesDownloadOption {
SEASON,
SERIES,
@@ -195,71 +94,140 @@ internal enum class SeriesDownloadOption {
DELETE_SMART
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DownloadOptionsDialog(
internal fun DownloadOptionsBottomSheet(
selectedSeasonName: String,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(SeriesDownloadDialogTag),
title = { Text("Download") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Download options",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Choose how to download this series.",
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
) {
Text(
when (seasonDownloadState) {
is DownloadState.Downloaded -> "$selectedSeasonName Downloaded"
is DownloadState.Downloading -> "Downloading $selectedSeasonName"
else -> "Download $selectedSeasonName"
}
)
}
TextButton(
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
) {
Text("Download All")
}
}
},
dismissButton = {
DownloadOptionRow(
title = "Download selected season",
supportingText = when (seasonDownloadState) {
is DownloadState.Downloaded -> "$selectedSeasonName is already downloaded."
is DownloadState.Downloading -> "$selectedSeasonName is downloading."
else -> "Save episodes from $selectedSeasonName for offline viewing."
},
icon = Icons.Outlined.Download,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
enabled = seasonDownloadState is DownloadState.NotDownloaded,
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
)
DownloadOptionRow(
title = "Download all episodes",
supportingText = when (seriesDownloadState) {
is DownloadState.Downloaded -> "All episodes in this series are already downloaded."
is DownloadState.Downloading -> "All episodes in this series are downloading."
else -> "Save every available episode in this series."
},
icon = Icons.Outlined.Download,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
enabled = seriesDownloadState is DownloadState.NotDownloaded,
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
)
if (isSmartDownloadEnabled) {
TextButton(
DownloadOptionRow(
title = "Delete series downloads",
supportingText = "Turns off Smart Downloads and removes offline episodes for this series.",
icon = Icons.Outlined.Delete,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete Smart Downloads")
}
destructive = true
)
} else {
TextButton(
DownloadOptionRow(
title = "Smart Downloads",
supportingText = "Automatically keep the next unwatched episodes available offline.",
icon = Icons.Outlined.AutoAwesome,
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) },
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag)
) {
Text("Smart Download")
}
)
}
}
}
}
@Composable
private fun DownloadOptionRow(
title: String,
supportingText: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
destructive: Boolean = false,
enabled: Boolean = true,
) {
val contentColor = if (!enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else if (destructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val supportingColor = if (!enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else if (destructive) {
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
ListItem(
headlineContent = {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = contentColor
)
},
supportingContent = {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
color = supportingColor
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = modifier.clickable(enabled = enabled, onClick = onClick)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SeasonTabs(
seasons: List<Season>,
@@ -267,74 +235,81 @@ internal fun SeasonTabs(
modifier: Modifier = Modifier,
onSelect: (Season) -> Unit
) {
var showBottomSheet by remember { mutableStateOf(false) }
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
seasons.forEachIndexed { index, season ->
SeasonTab(
name = season.name,
isSelected = season == selectedSeason,
modifier = Modifier
.testTag("$SeriesSeasonTabTagPrefix$index")
.clickable { onSelect(season) }
)
}
}
}
@Composable
private fun SeasonTab(
name: String,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
val color = if (isSelected) scheme.primary else mutedStrong
val borderColor = if (isSelected) scheme.primary else Color.Transparent
Column(
modifier = modifier
.padding(bottom = 8.dp)
.clickable { showBottomSheet = true }
.testTag(SeriesSeasonSelectorTag),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = name,
color = color,
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
text = selectedSeason?.name ?: "Select Season",
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.height(2.dp)
.width(52.dp)
.background(borderColor)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = "Select season",
tint = scheme.onSurfaceVariant
)
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
item {
Text(
text = "Select Season",
style = MaterialTheme.typography.titleLarge,
color = scheme.onSurface,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
}
items(seasons) { season ->
ListItem(
headlineContent = {
Text(text = season.name)
},
leadingContent = if (season == selectedSeason) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = scheme.primary
)
}
} else null,
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable {
onSelect(season)
showBottomSheet = false
}
)
}
}
}
}
}
@Composable
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(episodes) {
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
if (firstUnwatchedIndex != 0) {
listState.animateScrollToItem(firstUnwatchedIndex)
} else {
listState.scrollToItem(0)
}
}
LazyRow(
state = listState,
modifier = modifier.testTag(SeriesEpisodeCarouselTag),
horizontalArrangement = Arrangement.spacedBy(16.dp)
Column(
modifier = modifier
.fillMaxWidth()
.testTag(SeriesEpisodeCarouselTag),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(episodes, key = { episode -> episode.id }) { episode ->
episodes.forEach { episode ->
EpisodeCard(episode = episode)
}
}
@@ -347,20 +322,21 @@ private fun EpisodeCard(
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
Column(
Row(
modifier = Modifier
.width(260.dp)
.fillMaxWidth()
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
.clickable { viewModel.onSelectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
) },
verticalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.fillMaxWidth()
.width(132.dp)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surface)
@@ -377,28 +353,6 @@ private fun EpisodeCard(
.matchParentSize()
.background(scheme.background.copy(alpha = 0.2f))
)
Icon(
imageVector = Icons.Outlined.PlayCircle,
contentDescription = null,
tint = scheme.onBackground,
modifier = Modifier
.align(Alignment.Center)
.size(32.dp)
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.background(scheme.background.copy(alpha = 0.8f), RoundedCornerShape(6.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = episode.runtime,
color = scheme.onBackground,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
if (episode.watched.not() && (episode.progress ?: 0.0) > 0) {
MediaProgressBar(
progress = (episode.progress ?: 0.0).toFloat().div(100),
@@ -415,17 +369,19 @@ private fun EpisodeCard(
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Episode ${episode.index}",
text = "Episode ${episode.index}${episode.runtime}",
color = mutedStrong,
fontSize = 12.sp,
maxLines = 1,
@@ -438,11 +394,12 @@ private fun EpisodeCard(
internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesAddButtonTag = "series-add-button"
internal const val SeriesDownloadButtonTag = "series-download-button"
internal const val SeriesWatchedButtonTag = "series-watched-button"
internal const val SeriesDownloadDialogTag = "series-download-dialog"
internal const val SeriesDownloadSeasonButtonTag = "series-download-season-button"
internal const val SeriesDownloadAllButtonTag = "series-download-all-button"
internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
internal const val SeriesSeasonTabTagPrefix = "series-season-tab-"
internal const val SeriesSeasonSelectorTag = "series-season-selector"
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"

View File

@@ -13,16 +13,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -36,6 +39,7 @@ import hu.bbara.purefin.core.settings.StringSetting
import hu.bbara.purefin.core.settings.VoidSetting
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.screen.settings.components.BooleanSettingItem
import hu.bbara.purefin.ui.screen.settings.components.DropdownSettingItem
import hu.bbara.purefin.ui.screen.settings.components.RangeSettingItem
@@ -43,6 +47,7 @@ import hu.bbara.purefin.ui.screen.settings.components.ReadOnlySettingItem
import hu.bbara.purefin.ui.screen.settings.components.StringSettingItem
import hu.bbara.purefin.ui.screen.settings.components.VoidSettingItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
@@ -50,6 +55,7 @@ fun SettingsScreen(
) {
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
LaunchedEffect(viewModel) {
viewModel.snackbarMessages.collect { message ->
@@ -58,12 +64,17 @@ fun SettingsScreen(
}
Scaffold(
modifier = modifier.fillMaxSize(),
modifier = modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
SettingsTopBar(onBack = viewModel::onBack)
SettingsTopBar(
onBack = viewModel::onBack,
scrollBehavior = topBarScrollBehavior
)
}
) { innerPadding ->
LazyColumn(
@@ -77,7 +88,7 @@ fun SettingsScreen(
item(key = "${groupIndex}-title") {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = 16.dp,
@@ -224,11 +235,14 @@ private fun <T> DropdownSettingOptionItem(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsTopBar(
onBack: () -> Unit
onBack: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
DefaultTopBar(
scrollBehavior = scrollBehavior,
leftActions = {
DefaultTopBarIconButton(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.common.button
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilledIconButton
@@ -22,7 +21,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
internal fun CircularIconButton(
fun CircularIconButton(
icon: ImageVector,
contentDescription: String?,
containerColor: Color,
@@ -31,8 +30,6 @@ internal fun CircularIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBorderWidth: Dp,
focusedBorderColor: Color,
focusedBackgroundColor: Color = containerColor
) {
var isFocused by remember { mutableStateOf(false) }
@@ -40,10 +37,6 @@ internal fun CircularIconButton(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) focusedBorderColor else Color.Transparent,
label = "border"
)
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) focusedBackgroundColor else containerColor,
label = "background"
@@ -60,11 +53,6 @@ internal fun CircularIconButton(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(size)
.border(
width = if (isFocused) focusedBorderWidth else focusedBorderWidth * 0,
color = borderColor,
shape = CircleShape
)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
) {
Icon(

View File

@@ -0,0 +1,63 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
@Composable
fun CircularTextButton(
text: String,
textColor: Color,
fontSize: Int,
containerColor: Color,
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBackgroundColor: Color = containerColor
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) focusedBackgroundColor else containerColor,
label = "background"
)
FilledTonalButton(
onClick = onClick,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = backgroundColor,
contentColor = textColor
),
shape = CircleShape,
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.height(size)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
) {
Text(
text = text,
fontSize = fontSize.sp,
fontWeight = FontWeight.Bold
)
}
}

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.download.DownloadState
@Composable
fun DownloadActionButton(
downloadState: DownloadState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 48.dp,
) {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = height,
modifier = modifier,
onClick = onClick
)
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@@ -15,8 +14,6 @@ fun GhostIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 0.dp,
focusedBorderColor: Color = Color.Transparent,
focusedBackgroundColor: Color? = null
) {
val scheme = MaterialTheme.colorScheme
@@ -31,7 +28,5 @@ fun GhostIconButton(
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -30,7 +30,5 @@ fun MediaActionButton(
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@@ -16,8 +15,6 @@ fun PurefinIconButton(
modifier: Modifier = Modifier,
size: Int = 52,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 0.dp,
focusedBorderColor: Color = Color.Transparent,
focusedBackgroundColor: Color? = null
) {
val scheme = MaterialTheme.colorScheme
@@ -31,7 +28,5 @@ fun PurefinIconButton(
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -0,0 +1,28 @@
package hu.bbara.purefin.ui.common.dialog
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@Composable
fun ExitAppDialog(
onCloseConfirmed: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Close app?") },
text = { Text("Are you sure?") },
confirmButton = {
TextButton(onClick = onCloseConfirmed) {
Text("Close")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -3,21 +3,21 @@ package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MediaMetadataFlowRow(
items: List<String>,
modifier: Modifier = Modifier,
highlightedItem: String? = null,
highlightedBackground: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
highlightedBorder: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
highlightedTextColor: Color = MaterialTheme.colorScheme.primary
highlightedTextColor: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier
) {
FlowRow(
modifier = modifier,

View File

@@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
@@ -25,12 +26,13 @@ import javax.inject.Singleton
class CompositeLocalMediaRepository @Inject constructor(
@Offline private val offlineRepository: LocalMediaRepository,
@Online private val onlineRepository: LocalMediaRepository,
private val networkMonitor: NetworkMonitor,
) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff.
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository)
private val activeRepository: Flow<LocalMediaRepository> = networkMonitor.isOnline
.map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies }
@@ -45,35 +47,123 @@ class CompositeLocalMediaRepository @Inject constructor(
.stateIn(scope, Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository
.flatMapLatest { it.getMovie(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getMovie(id) },
offlineRead = { offlineRepository.getMovie(id) },
)
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository
.flatMapLatest { it.getSeries(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getSeries(id) },
offlineRead = { offlineRepository.getSeries(id) },
)
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return activeRepository
.flatMapLatest { it.getEpisode(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getEpisode(id) },
offlineRead = { offlineRepository.getEpisode(id) },
)
}
override suspend fun loadSeasons(seriesId: UUID) {
activeRepository.first().loadSeasons(seriesId)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasons(seriesId) },
offlineAction = { offlineRepository.loadSeasons(seriesId) },
)
}
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
offlineAction = { offlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
)
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
if (networkMonitor.isOnline.first()) {
runOnlineAction { onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) }
}
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
}
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
if (networkMonitor.isOnline.first()) {
runOnlineAction { onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) }
}
offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
}
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
val repository = onlineRepository
repository.markAsWatched(mediaId, watched)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.markAsWatched(mediaId, watched) },
offlineAction = { offlineRepository.markAsWatched(mediaId, watched) },
)
}
override suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
) {
// Server-side operation — delegates to active repository (both are no-ops)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks) },
offlineAction = { offlineRepository.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks) },
)
}
private suspend fun <T> getFromActiveRepository(
onlineRead: suspend () -> Flow<T>,
offlineRead: suspend () -> Flow<T>,
): Flow<T> {
if (!networkMonitor.isOnline.first()) {
return offlineRead()
}
return try {
onlineRead()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (!networkMonitor.checkConnection()) {
offlineRead()
} else {
throw error
}
}
}
private suspend fun runOnlineOrOfflineNoOp(
onlineAction: suspend () -> Unit,
offlineAction: suspend () -> Unit,
) {
if (!networkMonitor.isOnline.first()) {
offlineAction()
return
}
try {
onlineAction()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (!networkMonitor.checkConnection()) {
offlineAction()
} else {
throw error
}
}
}
private suspend fun runOnlineAction(action: suspend () -> Unit) {
try {
action()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (networkMonitor.checkConnection()) {
throw error
}
}
}
}

View File

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

View File

@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface LocalMediaRepository : LocalMediaUpdater {
interface LocalMediaRepository : MediaMetadataUpdater {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>

View File

@@ -1,8 +0,0 @@
package hu.bbara.purefin.core.data
import java.util.UUID
interface LocalMediaUpdater {
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
}

View File

@@ -0,0 +1,39 @@
package hu.bbara.purefin.core.data
import java.util.UUID
/**
* Unified interface for updating media metadata (watched status, watch progress,
* and server-side playback position).
*
* Implementations coordinate both local cache updates and remote server calls
* so that callers have a single point of entry for all metadata mutations.
*/
interface MediaMetadataUpdater {
/**
* Mark the given media item as watched or unwatched.
* Updates both the local cache and the remote server.
*/
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
/**
* Compute progress from absolute position/duration and persist it
* to the local cache (in-memory and/or Room).
*/
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
/**
* Persist a raw progress percentage (0.0 100.0) to the local cache.
*/
suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double)
/**
* Update the server-side playback position (in ticks) and optionally
* mark the item as played when past the threshold (≥ 80 %).
*/
suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
)
}

View File

@@ -4,8 +4,5 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
fun reportRequestSucceeded()
fun reportRequestFailed(error: Throwable)
suspend fun checkConnection(): Boolean
}

View File

@@ -1,13 +0,0 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface OfflineCatalogReader {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
}

View File

@@ -6,7 +6,7 @@ import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import java.util.UUID
interface OfflineCatalogStore {
interface OfflineMediaManager {
suspend fun saveMovies(movies: List<Movie>)
suspend fun saveSeries(series: List<Series>)
suspend fun saveSeason(season: Season)

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.core.data
/**
* Playback-only data stored in [androidx.media3.common.MediaItem.LocalConfiguration.tag].
*
* The player uses [playbackReportContext] for Jellyfin progress reporting and consumes the
* transcoding fallback fields once when direct playback fails.
*/
data class PlaybackMediaItemTag(
val playbackReportContext: PlaybackReportContext,
val transcodingFallbackUrl: String?,
)

View File

@@ -5,6 +5,5 @@ data class PlaybackReportContext(
val mediaSourceId: String?,
val audioStreamIndex: Int?,
val subtitleStreamIndex: Int?,
val liveStreamId: String?,
val playSessionId: String?,
)

View File

@@ -10,8 +10,9 @@ import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.OfflineCatalogStore
import hu.bbara.purefin.core.data.OfflineMediaManager
import hu.bbara.purefin.core.data.SmartDownloadStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -35,7 +36,7 @@ class MediaDownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadManager: DownloadManager,
private val downloadMediaSourceResolver: DownloadMediaSourceResolver,
private val offlineCatalogStore: OfflineCatalogStore,
private val offlineMediaManager: OfflineMediaManager,
private val smartDownloadStore: SmartDownloadStore,
) : MediaDownloadController {
@@ -124,7 +125,7 @@ class MediaDownloadManager @Inject constructor(
return@withContext
}
offlineCatalogStore.saveMovies(listOf(source.movie))
offlineMediaManager.saveMovies(listOf(source.movie))
Timber.tag(TAG).d("Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
@@ -146,7 +147,7 @@ class MediaDownloadManager @Inject constructor(
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
try {
offlineCatalogStore.deleteMovie(movieId)
offlineMediaManager.deleteMovie(movieId)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to remove movie from offline DB")
}
@@ -161,15 +162,15 @@ class MediaDownloadManager @Inject constructor(
return@withContext
}
if (offlineCatalogStore.getSeriesBasic(source.series.id) == null) {
offlineCatalogStore.saveSeries(listOf(source.series))
if (offlineMediaManager.getSeriesBasic(source.series.id) == null) {
offlineMediaManager.saveSeries(listOf(source.series))
}
if (offlineCatalogStore.getSeason(source.season.id) == null) {
offlineCatalogStore.saveSeason(source.season)
if (offlineMediaManager.getSeason(source.season.id) == null) {
offlineMediaManager.saveSeason(source.season)
}
offlineCatalogStore.saveEpisode(source.episode)
offlineMediaManager.saveEpisode(source.episode)
Timber.tag(TAG).d("Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
val request = buildDownloadRequest(
@@ -199,7 +200,7 @@ class MediaDownloadManager @Inject constructor(
withContext(Dispatchers.IO) {
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
try {
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
offlineMediaManager.deleteEpisodeAndCleanup(episodeId)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to remove episode from offline DB")
}
@@ -216,7 +217,7 @@ class MediaDownloadManager @Inject constructor(
override suspend fun deleteSmartDownloads(seriesId: UUID) {
withContext(Dispatchers.IO) {
smartDownloadStore.disable(seriesId)
offlineCatalogStore.getEpisodesBySeries(seriesId).forEach { episode ->
offlineMediaManager.getEpisodesBySeries(seriesId).forEach { episode ->
cancelEpisodeDownload(episode.id)
}
}
@@ -230,6 +231,8 @@ class MediaDownloadManager @Inject constructor(
for (seriesId in enabledSeriesIds) {
try {
syncSmartDownloadsForSeries(seriesId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Smart download sync failed for series $seriesId")
}
@@ -240,7 +243,7 @@ class MediaDownloadManager @Inject constructor(
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
withContext(Dispatchers.IO) {
// 1. Get currently downloaded episodes for this series
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
val downloadedEpisodes = offlineMediaManager.getEpisodesBySeries(seriesId)
// 2. Check watched status from server and delete watched downloads
val unwatchedDownloaded = mutableListOf<UUID>()

View File

@@ -7,8 +7,8 @@ import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshCoordinator
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.settings.SettingsRepository
import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.LibraryUiModel
@@ -39,21 +39,24 @@ class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val localMediaRepository: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
private val mediaMetadataUpdater: MediaMetadataUpdater,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
private val homeRefreshCoordinator: HomeRefreshCoordinator,
private val settingsRepository: SettingsRepository,
networkMonitor: NetworkMonitor,
private val networkMonitor: NetworkMonitor,
) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _isCheckingConnection = MutableStateFlow(false)
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
true
)
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
@@ -65,16 +68,17 @@ class AppViewModel @Inject constructor(
val libraries = homeRepository.libraries.map { libraries ->
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(
id = it.id,
name = it.name,
type = it.type,
posterUrl = it.posterUrl,
size = it.size,
isEmpty = when (it.type) {
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
}
isEmpty = it.size == 0,
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -182,7 +186,7 @@ class AppViewModel @Inject constructor(
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
}
}
@@ -253,34 +257,31 @@ class AppViewModel @Inject constructor(
fun onResumed() {
viewModelScope.launch {
try {
homeRepository.refreshHomeData()
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
}
}
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
networkMonitor.checkConnection()
homeRefreshCoordinator.onResumed()
}
}
fun onRefresh() {
viewModelScope.launch {
_isRefreshing.value = true
try {
homeRepository.refreshHomeData()
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
} finally {
_isRefreshing.value = false
networkMonitor.checkConnection()
homeRefreshCoordinator.onRefresh { isRefreshing ->
_isRefreshing.value = isRefreshing
}
}
}
fun checkConnection() {
viewModelScope.launch {
try {
mediaDownloadManager.syncSmartDownloads()
} catch (_: Exception) { }
if (_isCheckingConnection.value) return@launch
_isCheckingConnection.value = true
val isConnected = networkMonitor.checkConnection()
_isCheckingConnection.value = false
if (isConnected) {
homeRefreshCoordinator.onRefresh { isRefreshing ->
_isRefreshing.value = isRefreshing
}
}
}
}

View File

@@ -0,0 +1,93 @@
package hu.bbara.purefin.core.feature.browse.home.refresh
import android.os.SystemClock
import hu.bbara.purefin.core.data.HomeRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
// Singleton so the debounce window is shared across all ViewModel instances.
// A new coordinator per ViewModel would reset the timer on configuration
// changes and defeat the purpose of coalescing rapid resume/tab-switch
// events.
@Singleton
class HomeRefreshCoordinator @Inject constructor(
private val homeRepository: HomeRepository,
private val sideEffects: Set<@JvmSuppressWildcards HomeRefreshSideEffect>,
) {
private val sideEffectsMutex = Mutex()
// AtomicLong matches the rate-limit pattern used by the playback-position
// side effect and is safe to read/write from any coroutine dispatcher.
@OptIn(ExperimentalAtomicApi::class)
private val lastRefreshAtMs = AtomicLong(0L)
@OptIn(ExperimentalAtomicApi::class)
suspend fun onResumed() {
val now = SystemClock.elapsedRealtime()
if (now - lastRefreshAtMs.load() < RESUME_DEBOUNCE_MS) {
Timber.tag(TAG).d("Skipping onResumed refresh (debounced)")
return
}
refreshHomeData()
runSideEffects()
// Updated unconditionally on completion. refreshHomeData() catches
// non-cancellation exceptions internally, so a failed refresh still
// counts as "we just touched the server" and the next onResumed
// within RESUME_DEBOUNCE_MS will be skipped. Pull-to-refresh always
// bypasses this gate via onRefresh.
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
}
@OptIn(ExperimentalAtomicApi::class)
suspend fun onRefresh(setRefreshing: (Boolean) -> Unit) {
refreshHomeData(setRefreshing)
runSideEffects()
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
}
private suspend fun refreshHomeData(
setRefreshing: ((Boolean) -> Unit)? = null,
) {
setRefreshing?.invoke(true)
try {
homeRepository.refreshHomeData()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
} finally {
setRefreshing?.invoke(false)
}
}
private suspend fun runSideEffects() {
sideEffectsMutex.withLock {
coroutineScope {
sideEffects.forEach { sideEffect ->
launch {
try {
sideEffect.run()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) { }
}
}
}
}
}
private companion object {
const val TAG = "HomeRefreshCoordinator"
// Coalesce rapid resume/tab-switch events into a single refresh.
// Pull-to-refresh bypasses this gate via onRefresh.
const val RESUME_DEBOUNCE_MS = 30_000L
}
}

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.core.feature.browse.home.refresh
interface HomeRefreshSideEffect {
suspend fun run()
}

View File

@@ -0,0 +1,18 @@
package hu.bbara.purefin.core.feature.browse.home.refresh
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
@Module
@InstallIn(SingletonComponent::class)
abstract class HomeRefreshSideEffectModule {
@Binds
@IntoSet
abstract fun bindSyncSmartDownloadsHomeRefreshSideEffect(
impl: SyncSmartDownloadsHomeRefreshSideEffect
): HomeRefreshSideEffect
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.core.feature.browse.home.refresh
import hu.bbara.purefin.core.download.MediaDownloadController
import javax.inject.Inject
class SyncSmartDownloadsHomeRefreshSideEffect @Inject constructor(
private val mediaDownloadController: MediaDownloadController,
) : HomeRefreshSideEffect {
override suspend fun run() {
mediaDownloadController.syncSmartDownloads()
}
}

View File

@@ -4,19 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.MediaMetadataUpdater
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.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.LibraryKind
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@@ -25,28 +21,40 @@ import javax.inject.Inject
class LibraryViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val navigationManager: NavigationManager,
private val mediaMetadataUpdater: MediaMetadataUpdater,
) : ViewModel() {
private val selectedLibrary = MutableStateFlow<UUID?>(null)
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
libraryId, libraries ->
if (libraryId == null) {
return@combine emptyList()
}
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
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())
// Local cache of the last-fetched content per library. Used to preserve
// content when re-selecting a library whose ETag matches (so the
// repository's 304 response can be treated as "use the cached copy").
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
init {
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) {
@@ -69,6 +77,12 @@ class LibraryViewModel @Inject constructor(
}
}
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
}
}
fun onBack() {
navigationManager.pop()
}

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
@@ -30,6 +31,7 @@ class EpisodeScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
private val mediaMetadataUpdater: MediaMetadataUpdater,
): ViewModel() {
private val _episode = MutableStateFlow<EpisodeDto?>(null)
@@ -81,6 +83,13 @@ class EpisodeScreenViewModel @Inject constructor(
}
}
fun markAsWatched(watched: Boolean) {
val episodeId = _episode.value?.id ?: return
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(episodeId, watched)
}
}
fun onDownloadClick() {
val episodeId = _episode.value?.id ?: return
viewModelScope.launch {

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
@@ -28,6 +29,7 @@ class MovieScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
private val mediaMetadataUpdater: MediaMetadataUpdater,
): ViewModel() {
private val _movie = MutableStateFlow<MovieDto?>(null)
@@ -68,6 +70,13 @@ class MovieScreenViewModel @Inject constructor(
}
}
fun markAsWatched(watched: Boolean) {
val movieId = movie.value?.id ?: return
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(movieId, watched)
}
}
fun onDownloadClick() {
val movieId = movie.value?.id ?: return
viewModelScope.launch {

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
@@ -33,6 +34,7 @@ class SeriesViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
private val mediaMetadataUpdater: MediaMetadataUpdater,
) : ViewModel() {
private val _series = MutableStateFlow<SeriesDto?>(null)
@@ -183,6 +185,13 @@ class SeriesViewModel @Inject constructor(
navigationManager.replaceAll(Route.Home)
}
fun markAsWatched(watched: Boolean) {
val seriesId = _series.value?.id ?: return
viewModelScope.launch {
mediaMetadataUpdater.markAsWatched(seriesId, watched)
}
}
fun selectSeries(series: SeriesDto) {
_series.value = series
viewModelScope.launch {

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.feature.downloads
import hu.bbara.purefin.core.model.MediaUiModel
data class DownloadedItem(
val media: MediaUiModel,
)

View File

@@ -48,16 +48,25 @@ class DownloadsViewModel @Inject constructor(
private val activeDownloadsMap = downloadManager.observeActiveDownloads()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** Items that are fully downloaded and not currently in progress. */
/** Offline items with saved content available, excluding items that are only currently in progress. */
val downloads = combine(
offlineCatalogReader.movies,
offlineCatalogReader.series,
activeDownloadsMap
) { movies, series, inProgress ->
offlineCatalogReader.episodes,
activeDownloadsMap,
) { movies, series, episodes, inProgress ->
movies.values
.filter { it.id.toString() !in inProgress }
.map { MovieUiModel(it) } +
series.values.map { SeriesUiModel(it) }
.map { DownloadedItem(media = MovieUiModel(it)) } +
series.values.filter { series ->
episodes.values.any { episode ->
episode.seriesId == series.id && episode.id.toString() !in inProgress
}
}.map {
DownloadedItem(
media = SeriesUiModel(it),
)
}
}
/** Items currently being downloaded with their progress. */

View File

@@ -1,7 +0,0 @@
package hu.bbara.purefin.core.jellyfin
import java.util.UUID
interface JellyfinMediaMetadataUpdater {
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
}

View File

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

View File

@@ -6,7 +6,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.CompositeLocalMediaRepository
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.LocalMediaUpdater
@Module
@@ -15,7 +14,4 @@ abstract class RepositoryModule {
@Binds
abstract fun bindMediaRepository(impl: CompositeLocalMediaRepository): LocalMediaRepository
@Binds
abstract fun bindMediaProgressWrite(impl: CompositeLocalMediaRepository): LocalMediaUpdater
}

View File

@@ -10,6 +10,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackMediaItemTag
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.player.model.MetadataState
import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot
@@ -76,6 +78,7 @@ class PlayerManager @Inject constructor(
}
private var pendingSeekPositionMs: Long? = null
private var pendingFallbackSeek: PendingFallbackSeek? = null
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
@@ -107,6 +110,9 @@ class PlayerManager @Inject constructor(
}
override fun onPlayerError(error: PlaybackException) {
if (switchToTranscodingFallback()) {
return
}
_playbackState.update {
it.copy(error = mapPlayerError(error))
}
@@ -122,10 +128,15 @@ class PlayerManager @Inject constructor(
_playbackState.update { it.copy(error = "Media not found in playlist") }
return@launch
}
seekTo(currentMedia.resumePositionMs)
val fallbackSeek = pendingFallbackSeek
pendingFallbackSeek = null
val fallbackSeekPositionMs = fallbackSeek
?.takeIf { it.mediaId == mediaItem?.mediaId }
?.positionMs
seekTo(fallbackSeekPositionMs ?: currentMedia.resumePositionMs)
installMediaSegments(currentMedia.mediaSegments)
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
if (currentMedia is PlayableMedia.Episode) {
if (fallbackSeekPositionMs == null && currentMedia is PlayableMedia.Episode) {
updatePlaylist()
}
}
@@ -177,7 +188,7 @@ class PlayerManager @Inject constructor(
}
private suspend fun playNewMedia(mediaId: UUID) {
var playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
val playableMedia = playableMediaRepository.getPlayableMedia(mediaId)
if (playableMedia == null) {
_playbackState.update { it.copy(error = "Media not found") }
return
@@ -424,7 +435,7 @@ class PlayerManager @Inject constructor(
private fun updateFromPlayer(player: Player) {
val currentMediaItem = player.currentMediaItem
val playbackState = player.playbackState
val playbackReportContext = currentMediaItem?.localConfiguration?.tag as? PlaybackReportContext
val playbackReportContext = currentMediaItem?.playbackReportContext()
val currentMetadata = player.mediaMetadata
val playerError = player.playerError
@@ -487,6 +498,96 @@ class PlayerManager @Inject constructor(
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
}
private fun switchToTranscodingFallback(): Boolean {
val currentMediaItem = player.currentMediaItem ?: return false
val playbackTag = currentMediaItem.localConfiguration?.tag as? PlaybackMediaItemTag
?: return false
val fallbackUrl = playbackTag.transcodingFallbackUrl?.takeIf { it.isNotBlank() } ?: return false
val currentMediaItemIndex = player.currentMediaItemIndex
if (currentMediaItemIndex == C.INDEX_UNSET) return false
val fallbackMediaItem = currentMediaItem.buildUpon()
.setUri(fallbackUrl)
.setTag(
playbackTag.copy(
playbackReportContext = playbackTag.playbackReportContext.copy(
playMethod = PlaybackMethod.TRANSCODE
),
transcodingFallbackUrl = null,
)
)
.build()
val fallbackPositionMs = fallbackPositionMs()
val playWhenReady = player.playWhenReady
pendingFallbackSeek = PendingFallbackSeek(
mediaId = currentMediaItem.mediaId,
positionMs = fallbackPositionMs,
)
clearStalePendingFallbackSeek(currentMediaItem.mediaId)
player.replaceMediaItem(currentMediaItemIndex, fallbackMediaItem)
replacePlaylistMediaItem(fallbackMediaItem)
clearActiveSkippableSegment()
player.seekTo(currentMediaItemIndex, fallbackPositionMs)
player.prepare()
player.playWhenReady = playWhenReady
_playbackState.update { it.copy(error = null) }
return true
}
private fun fallbackPositionMs(): Long {
val positionMs = pendingSeekPositionMs ?: player.currentPosition
return if (positionMs > 0L) {
positionMs
} else {
_progress.value.positionMs.coerceAtLeast(0L)
}
}
private fun replacePlaylistMediaItem(mediaItem: MediaItem) {
val mediaId = runCatching { UUID.fromString(mediaItem.mediaId) }.getOrNull() ?: return
_playlist.update { playlist ->
playlist.map { playableMedia ->
if (playableMedia.id == mediaId) {
playableMedia.withMediaItem(mediaItem)
} else {
playableMedia
}
}
}
}
private fun PlayableMedia.withMediaItem(mediaItem: MediaItem): PlayableMedia {
return when (this) {
is PlayableMedia.Episode -> copy(mediaItem = mediaItem)
is PlayableMedia.Movie -> copy(mediaItem = mediaItem)
is PlayableMedia.Series -> copy(mediaItem = mediaItem)
}
}
private fun MediaItem.playbackReportContext(): PlaybackReportContext? {
return when (val tag = localConfiguration?.tag) {
is PlaybackMediaItemTag -> tag.playbackReportContext
is PlaybackReportContext -> tag
else -> null
}
}
private fun clearStalePendingFallbackSeek(mediaId: String) {
scope.launch {
delay(1_000)
if (pendingFallbackSeek?.mediaId == mediaId) {
pendingFallbackSeek = null
}
}
}
private data class PendingFallbackSeek(
val mediaId: String,
val positionMs: Long,
)
private fun syncPendingSeek(positionMs: Long) {
val pendingPosition = pendingSeekPositionMs ?: return
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.core.player.manager
import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.LocalMediaUpdater
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.player.model.MetadataState
@@ -24,7 +24,7 @@ import javax.inject.Inject
@ViewModelScoped
class ProgressManager @Inject constructor(
private val playbackProgressReporter: PlaybackProgressReporter,
private val localMediaUpdater: LocalMediaUpdater,
private val mediaMetadataUpdater: MediaMetadataUpdater,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null
@@ -80,7 +80,7 @@ class ProgressManager @Inject constructor(
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) {
try {
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Local cache update failed")
}
@@ -127,7 +127,7 @@ class ProgressManager @Inject constructor(
activePlaybackReportContext?.let { reportContext ->
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
}
localMediaUpdater.updateWatchProgress(itemId, posMs, durMs)
mediaMetadataUpdater.updateWatchProgress(itemId, posMs, durMs)
Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Report failed")

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.core.player.model
import hu.bbara.purefin.model.SegmentType
data class PlayerUiState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
@@ -11,11 +13,13 @@ data class PlayerUiState(
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val activeSkippableSegmentEndMs: Long? = null,
val activeSkippableSegmentType: SegmentType? = null,
val error: String? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),
val ads: List<TimedMarker> = emptyList(),
val queue: List<PlaylistElementUiModel> = emptyList(),
val nextEpisode: PlaylistElementUiModel? = null,
val audioTracks: List<TrackOption> = emptyList(),
val textTracks: List<TrackOption> = emptyList(),
val qualityTracks: List<TrackOption> = emptyList(),

View File

@@ -13,6 +13,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.util.EventLogger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -69,6 +70,7 @@ object VideoPlayerModule {
.apply {
playWhenReady = true
pauseAtEndOfMediaItems = false
addAnalyticsListener(EventLogger())
}
}
}

View File

@@ -1,71 +0,0 @@
package hu.bbara.purefin.core.player.viewmodel
enum class ControlsAutoHideBlocker {
PLAYLIST,
TRACK_PANEL
}
internal sealed interface ControlsAutoHideCommand {
data object Cancel : ControlsAutoHideCommand
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
}
internal class ControlsAutoHidePolicy(
private val defaultDelayMs: Long
) {
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
private var isPlaying = false
var controlsVisible: Boolean = true
private set
var lastAutoHideDelayMs: Long = defaultDelayMs
private set
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
this.isPlaying = isPlaying
return nextCommand()
}
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
lastAutoHideDelayMs = delayMs
return nextCommand()
}
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
delayMs?.let { lastAutoHideDelayMs = it }
controlsVisible = true
return nextCommand()
}
fun toggleControlsVisibility(): ControlsAutoHideCommand {
controlsVisible = !controlsVisible
return nextCommand()
}
fun hideControls(): ControlsAutoHideCommand {
controlsVisible = false
return ControlsAutoHideCommand.Cancel
}
fun setBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
): ControlsAutoHideCommand {
if (blocked) {
blockers += blocker
controlsVisible = true
} else {
blockers -= blocker
}
return nextCommand()
}
private fun nextCommand(): ControlsAutoHideCommand {
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
ControlsAutoHideCommand.Cancel
} else {
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
}
}
}

View File

@@ -14,7 +14,6 @@ import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.model.PlayableMedia
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -22,8 +21,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlinx.serialization.InternalSerializationApi
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@@ -46,10 +45,6 @@ class PlayerViewModel @Inject constructor(
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
private val _controlsVisible = MutableStateFlow(true)
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
playerManager.seekBy(deltaMs)
}
@@ -77,12 +72,6 @@ class PlayerViewModel @Inject constructor(
error = state.error ?: dataErrorMessage
)
}
applyControlsAutoHideCommand(
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
)
if (state.isEnded) {
showControls()
}
}
}
@@ -128,7 +117,10 @@ class PlayerViewModel @Inject constructor(
viewModelScope.launch {
playerManager.activeSkippableSegment.collect { mediaSegment ->
_uiState.update {
it.copy(activeSkippableSegmentEndMs = mediaSegment?.endMs)
it.copy(
activeSkippableSegmentEndMs = mediaSegment?.endMs,
activeSkippableSegmentType = mediaSegment?.type
)
}
}
}
@@ -139,9 +131,16 @@ class PlayerViewModel @Inject constructor(
playableMedia.toPlaylistElementUiModel(currentPlayableMedia?.id)
}
}.collect { queue ->
val currentIndex = queue.indexOfFirst { it.isCurrent }
val nextEpisode = if (currentIndex != -1 && currentIndex + 1 < queue.size) {
queue[currentIndex + 1]
} else {
null
}
_uiState.update { state ->
state.copy(
queue = queue
queue = queue,
nextEpisode = nextEpisode
)
}
}
@@ -181,9 +180,8 @@ class PlayerViewModel @Inject constructor(
}
}
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun togglePlayPause() {
playerManager.togglePlayPause()
showControls(autoHideDelayMs)
}
fun pausePlayback() {
@@ -213,49 +211,14 @@ class PlayerViewModel @Inject constructor(
playerManager.skipActiveSegment()
}
fun setControlsAutoHideBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setBlocked(blocker, blocked)
)
}
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.showControls(autoHideDelayMs)
)
}
fun toggleControlsVisibility() {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.toggleControlsVisibility()
)
}
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
autoHideJob?.cancel()
autoHideJob = null
if (command !is ControlsAutoHideCommand.Schedule) return
autoHideJob = viewModelScope.launch {
delay(command.delayMs)
controlsAutoHidePolicy.hideControls()
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
}
}
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun next() {
seekByCollector.clear()
playerManager.next()
showControls(autoHideDelayMs)
}
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
fun previous() {
seekByCollector.clear()
playerManager.previous()
showControls(autoHideDelayMs)
}
fun selectTrack(option: TrackOption) {
@@ -269,7 +232,6 @@ class PlayerViewModel @Inject constructor(
fun playQueueItem(id: String) {
seekByCollector.clear()
playerManager.play(id.toUuidOrNull() ?: return)
showControls()
}
fun clearError() {

View File

@@ -1,90 +0,0 @@
package hu.bbara.purefin.core.player.viewmodel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ControlsAutoHidePolicyTest {
@Test
fun activeBlockerCancelsScheduledAutoHide() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
assertEquals(
ControlsAutoHideCommand.Schedule(3_500L),
policy.onPlaybackChanged(isPlaying = true)
)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
)
assertTrue(policy.controlsVisible)
}
@Test
fun playlistBlockerPreventsAutoHideUntilCleared() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.showControls(delayMs = 5_000L)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
)
}
@Test
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
)
policy.showControls(delayMs = 5_000L)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
)
}
@Test
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.showControls(delayMs = 5_000L)
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
)
assertEquals(
ControlsAutoHideCommand.Schedule(5_000L),
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
)
}
@Test
fun blockingStateMakesHiddenControlsVisibleAgain() {
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
policy.onPlaybackChanged(isPlaying = true)
policy.toggleControlsVisibility()
assertEquals(
ControlsAutoHideCommand.Cancel,
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
)
assertTrue(policy.controlsVisible)
}
}

View File

@@ -4,6 +4,9 @@ import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.NetworkMonitor
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.toLibrary
import hu.bbara.purefin.data.converter.toMovie
@@ -24,6 +27,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -33,14 +37,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import timber.log.Timber
@Singleton
class InMemoryAppContentRepository @Inject constructor(
@@ -51,6 +55,7 @@ class InMemoryAppContentRepository @Inject constructor(
private val networkMonitor: NetworkMonitor,
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var cacheLoadJob: Job? = null
private var refreshJob: Job? = null
@OptIn(ExperimentalAtomicApi::class)
@@ -71,10 +76,19 @@ class InMemoryAppContentRepository @Inject constructor(
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
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 {
scope.launch {
ensureReady()
}
ensureReady()
}
@OptIn(ExperimentalAtomicApi::class)
@@ -83,17 +97,23 @@ class InMemoryAppContentRepository @Inject constructor(
return
}
Timber.tag(TAG).d("Initializing home repository")
scope.launch { loadHomeCache() }
scope.launch { refreshHomeData() }
val loadJob = scope.launch { loadHomeCache() }
cacheLoadJob = loadJob
scope.launch {
loadJob.join()
refreshHomeData()
}
}
override suspend fun refreshHomeData() {
cacheLoadJob?.join()
val job = synchronized(this) {
refreshJob?.takeIf { it.isActive } ?: scope.launch {
runCatching {
val snapshot = snapshotHomeContent()
try {
Timber.tag(TAG).d("Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
return@launch
}
loadLibraries()
loadSuggestions()
@@ -102,8 +122,15 @@ class InMemoryAppContentRepository @Inject constructor(
loadLatestLibraryContent()
Timber.tag(TAG).d("Home refresh successful")
persistHomeCache()
}.onFailure { error ->
} catch (error: CancellationException) {
throw error
} catch (error: HomeRefreshFailedException) {
restoreHomeContent(snapshot)
Timber.tag(TAG).w(error.cause, "Home refresh failed; keeping cached content")
} catch (error: Exception) {
restoreHomeContent(snapshot)
Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
networkMonitor.checkConnection()
}
}.also { refreshJob = it }
}
@@ -113,6 +140,14 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadHomeCache() {
Timber.tag(TAG).d("Loading home cache")
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()) {
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
librariesState.value = libraries
@@ -155,6 +190,12 @@ class InMemoryAppContentRepository @Inject constructor(
val movies = onlineMediaRepository.movies.value
val series = onlineMediaRepository.series.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(
suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
@@ -163,6 +204,7 @@ class InMemoryAppContentRepository @Inject constructor(
uuid.toString() to items.map { it.toCachedItem() }
}.toMap(),
libraries = librariesState.value.map { it.toCachedLibrary() },
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
@@ -200,49 +242,90 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load libraries")
return
handleRefreshFailure(error, "Unable to load libraries")
}
// Build the new library list in a single pass, so collectors of
// `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)
}
}
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
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)
}
private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load library ${library.id}")
return library
}
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList()
// 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 -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl()) },
size = contentItem.size,
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl()) },
size = contentItem.size,
)
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 ->
handleRefreshFailure(error, "Unable to load suggestions head")
}
if (headItems == null) return
if (headMatches(headItems, suggestionsState.value)) return
// Head changed (or first refresh) — fetch the full list.
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load suggestions")
return
handleRefreshFailure(error, "Unable to load suggestions")
}
if (suggestionsItems == null) return
suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
@@ -251,19 +334,31 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
// Upsert full details so the home viewmodel can look up each item.
suggestionsItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
}
}
}
private suspend fun 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() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load continue watching")
return
handleRefreshFailure(error, "Unable to load continue watching")
}
if (continueWatchingItems == null) return
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
@@ -273,74 +368,159 @@ class InMemoryAppContentRepository @Inject constructor(
}
continueWatchingItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
}
}
}
private suspend fun 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() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load next up")
return
handleRefreshFailure(error, "Unable to load next up")
}
if (nextUpItems == null) return
nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
}
nextUpItems.forEach { item ->
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
}
}
private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest library content")
return
}
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
// the same /Users/{userId}/Views response a second time per refresh.
val filteredLibraries = librariesState.value
val url = serverUrl()
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}")
emptyList()
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl())
Media.MovieMedia(movieId = movie.id)
}
CollectionType.TVSHOWS -> latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl())
Media.SeriesMedia(seriesId = series.id)
// 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) }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
}
BaseItemKind.SEASON -> {
val season = it.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
if (latestFromLibrary == null) {
library.id to cachedSlice
} else {
val media = when (library.type) {
LibraryKind.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(url)
onlineMediaRepository.upsertMovies(listOf(movie))
Media.MovieMedia(movieId = movie.id)
}
LibraryKind.SERIES -> latestFromLibrary.map { dto ->
when (dto.type) {
BaseItemKind.SERIES -> {
val series = dto.toSeries(url)
onlineMediaRepository.upsertSeries(listOf(series))
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
val season = dto.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = dto.toEpisode(url)
onlineMediaRepository.upsertEpisodes(listOf(episode))
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}")
}
}
}
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl())
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
library.id to media
}
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
}
}
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 {
return userSessionRepository.serverUrl.first()
}
private fun snapshotHomeContent(): HomeContentSnapshot = HomeContentSnapshot(
libraries = librariesState.value,
suggestions = suggestionsState.value,
continueWatching = continueWatchingState.value,
nextUp = nextUpState.value,
latestLibraryContent = latestLibraryContentState.value,
)
private fun restoreHomeContent(snapshot: HomeContentSnapshot) {
librariesState.value = snapshot.libraries
suggestionsState.value = snapshot.suggestions
continueWatchingState.value = snapshot.continueWatching
nextUpState.value = snapshot.nextUp
latestLibraryContentState.value = snapshot.latestLibraryContent
}
private suspend fun handleRefreshFailure(error: Throwable, message: String): Nothing {
if (error is CancellationException) {
throw error
}
Timber.tag(TAG).w(error, message)
networkMonitor.checkConnection()
throw HomeRefreshFailedException(error)
}
companion object {
private const val TAG = "InMemoryAppContentRepo"
}
@@ -351,3 +531,13 @@ private data class ReferencedHomeMediaIds(
val seriesIds: Set<UUID>,
val episodeIds: Set<UUID>,
)
private data class HomeContentSnapshot(
val libraries: List<Library>,
val suggestions: List<Media>,
val continueWatching: List<Media>,
val nextUp: List<Media>,
val latestLibraryContent: Map<UUID, List<Media>>,
)
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)

View File

@@ -167,12 +167,18 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
updateWatchProgressPercent(mediaId, progressPercent)
}
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
if (progressPercent.isNaN()) return
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
val watched = normalizedProgressPercent >= 90.0
if (moviesState.value.containsKey(mediaId)) {
moviesState.update { current ->
val movie = current[mediaId] ?: return@update current
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
current + (mediaId to movie.copy(progress = normalizedProgressPercent, watched = watched))
}
return
}
@@ -180,7 +186,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
var updatedEpisode: Episode? = null
episodesState.update { current ->
val episode = current[mediaId] ?: return@update current
val updated = episode.copy(progress = progressPercent, watched = watched)
val updated = episode.copy(progress = normalizedProgressPercent, watched = watched)
updatedEpisode = updated
current + (mediaId to updated)
}
@@ -208,6 +214,14 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
}
override suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
) {
// Server-side operation — not persisted locally
}
private fun updateLoadedSeriesEpisode(updatedEpisode: Episode) {
seriesState.update { current ->
val series = current[updatedEpisode.seriesId] ?: return@update current

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.OfflineMediaManager
import hu.bbara.purefin.data.offline.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,7 +22,7 @@ import javax.inject.Singleton
@Singleton
class OfflineLocalMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource,
) : LocalMediaRepository {
) : LocalMediaRepository, OfflineMediaManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
@@ -55,11 +57,61 @@ class OfflineLocalMediaRepository @Inject constructor(
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
updateWatchProgressPercent(mediaId, progressPercent)
}
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
if (progressPercent.isNaN()) return
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
val watched = normalizedProgressPercent >= 90.0
localDataSource.updateWatchProgress(mediaId, normalizedProgressPercent, watched)
}
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
// Do nothing
}
override suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
) {
// Server-side operation — not persisted offline
}
override suspend fun saveMovies(movies: List<Movie>) {
localDataSource.saveMovies(movies)
}
override suspend fun saveSeries(series: List<Series>) {
localDataSource.saveSeries(series)
}
override suspend fun saveSeason(season: Season) {
localDataSource.saveSeason(season)
}
override suspend fun saveEpisode(episode: Episode) {
localDataSource.saveEpisode(episode)
}
override suspend fun getSeriesBasic(seriesId: UUID): Series? {
return localDataSource.getSeriesBasic(seriesId)
}
override suspend fun getSeason(seasonId: UUID): Season? {
return localDataSource.getSeason(seasonId)
}
override suspend fun deleteMovie(movieId: UUID) {
localDataSource.deleteMovie(movieId)
}
override suspend fun deleteEpisodeAndCleanup(episodeId: UUID) {
localDataSource.deleteEpisodeAndCleanup(episodeId)
}
override suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return localDataSource.getEpisodesBySeries(seriesId)
}
}

View File

@@ -7,54 +7,48 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityNetworkMonitor @Inject constructor(
@param:ApplicationContext private val context: Context,
@ApplicationContext private val context: Context,
private val jellyfinApiClient: JellyfinApiClient,
) : NetworkMonitor {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected())
private val isServerReachable = MutableStateFlow(true)
private val isDeviceOnline: Flow<Boolean> = callbackFlow {
var wasDeviceOnline = connectivityManager.isCurrentlyConnected()
override val isOnline: Flow<Boolean> = combine(
isAndroidConnected,
isServerReachable,
) { androidConnected, serverReachable ->
androidConnected && serverReachable
}.distinctUntilChanged()
init {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
if (isCurrentlyConnected && !wasDeviceOnline) {
isServerReachable.value = true
}
wasDeviceOnline = isCurrentlyConnected
trySend(isCurrentlyConnected)
override fun onAvailable(network: Network) {
isAndroidConnected.value = true
scope.launch { updateServerReachability() }
}
override fun onLost(network: Network) {
val isCurrentlyConnected = connectivityManager.isCurrentlyConnected()
wasDeviceOnline = isCurrentlyConnected
trySend(isCurrentlyConnected)
isAndroidConnected.value = connectivityManager.isCurrentlyConnected()
}
}
@@ -62,51 +56,42 @@ class ConnectivityNetworkMonitor @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
trySend(wasDeviceOnline)
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
override val isOnline: StateFlow<Boolean> = combine(
isDeviceOnline,
isServerReachable
) { isDeviceOnline, isServerReachable ->
isDeviceOnline && isServerReachable
}.distinctUntilChanged()
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = connectivityManager.isCurrentlyConnected() && isServerReachable.value
)
override fun reportRequestSucceeded() {
isServerReachable.value = true
}
override fun reportRequestFailed(error: Throwable) {
if (error.isConnectivityFailure()) {
override suspend fun checkConnection(): Boolean {
val androidConnected = connectivityManager.isCurrentlyConnected()
isAndroidConnected.value = androidConnected
if (!androidConnected) {
isServerReachable.value = false
return false
}
return updateServerReachability()
}
private suspend fun updateServerReachability(): Boolean {
val reachable = try {
withTimeoutOrNull(SERVER_CHECK_TIMEOUT_MS) {
jellyfinApiClient.probeServer()
} ?: false
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).w(error, "Jellyfin server reachability check failed")
false
}
isServerReachable.value = reachable
return reachable
}
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
val network = activeNetwork ?: return false
val caps = getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun Throwable.isConnectivityFailure(): Boolean {
var current: Throwable? = this
while (current != null) {
when (current) {
is UnknownHostException,
is ConnectException,
is NoRouteToHostException,
is SocketTimeoutException -> return true
}
current = current.cause
}
return false
private companion object {
private const val TAG = "ConnectivityNetworkMonitor"
private const val SERVER_CHECK_TIMEOUT_MS = 5_000L
}
}

View File

@@ -8,7 +8,9 @@ import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.OfflineMediaManager
import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackMediaItemTag
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController
@@ -17,8 +19,7 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia
@@ -37,6 +38,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP
import org.jellyfin.sdk.model.api.MediaSourceInfo
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -49,6 +51,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
private val mediaDownloadController: MediaDownloadController,
private val networkMonitor: NetworkMonitor,
@param:Offline private val offlineMediaRepository: LocalMediaRepository,
private val offlineMediaManager: OfflineMediaManager,
) : PlayableMediaRepository {
override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) {
@@ -67,18 +70,31 @@ class DefaultPlayableMediaRepository @Inject constructor(
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}
return runCatching {
val onlinePlayableMedia = runCatching {
getOnlinePlayableMedia(mediaId, downloadedMediaItem)
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}.getOrElse { error ->
if (error is CancellationException) throw error
networkMonitor.checkConnection()
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId")
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
null
}
if (onlinePlayableMedia != null) return onlinePlayableMedia
networkMonitor.checkConnection()
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}
private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? {
return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
if (!networkMonitor.isOnline.first()) return null
return runCatching {
getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
}.getOrElse { error ->
if (error is CancellationException) throw error
networkMonitor.checkConnection()
Timber.tag(TAG).w(error, "Unable to load streaming media $mediaId")
null
}
}
private suspend fun getOnlinePlayableMedia(
@@ -86,14 +102,14 @@ class DefaultPlayableMediaRepository @Inject constructor(
downloadedMediaItem: MediaItem?,
): PlayableMedia? {
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return null
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(mediaId) ?: return null
val mediaItem = if (downloadedMediaItem == null) {
getMediaItem(baseItem, playbackDecision)
getMediaItem(baseItem, playbackSource)
} else {
getDownloadedMediaItem(baseItem, playbackDecision, downloadedMediaItem)
getDownloadedMediaItem(baseItem, downloadedMediaItem)
}
val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val resumePositionMs = calculateResumePosition(baseItem, playbackSource.mediaSource)
val preferenceMediaId = when (baseItem.type) {
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
else -> mediaId
@@ -145,7 +161,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
playbackReportContext = null,
),
resumePositionMs = 0L,
resumePositionMs = calculateOfflineResumePosition(movie.progress, movie.runtime, movie.watched),
preferences = preferences,
mediaSegments = emptyList()
)
@@ -163,7 +179,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
playbackReportContext = null,
),
resumePositionMs = 0L,
resumePositionMs = calculateOfflineResumePosition(episode.progress, episode.runtime, episode.watched),
preferences = preferences,
mediaSegments = emptyList()
)
@@ -172,7 +188,10 @@ class DefaultPlayableMediaRepository @Inject constructor(
return null
}
private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
private suspend fun getMediaItem(
baseItem: BaseItemDto,
playbackSource: PlaybackSource,
): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -181,11 +200,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
val mediaItem = createMediaItem(
mediaId = mediaId.toString(),
playbackDecision = playbackDecision,
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
url = playbackSource.directPlayUrl,
title = baseItem?.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
playbackTag = playbackSource.toPlaybackMediaItemTag(),
)
return@withContext mediaItem
@@ -193,7 +212,6 @@ class DefaultPlayableMediaRepository @Inject constructor(
private suspend fun getDownloadedMediaItem(
baseItem: BaseItemDto,
playbackDecision: PlaybackDecision,
downloadedMediaItem: MediaItem,
): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id
@@ -203,10 +221,11 @@ class DefaultPlayableMediaRepository @Inject constructor(
return@withContext downloadedMediaItem.withMetadata(
mediaId = mediaId.toString(),
title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown",
title = baseItem?.name ?: "Unknown",
subtitle = seasonEpisodeLabel(baseItem),
artworkUrl = artworkUrl,
playbackReportContext = playbackDecision.reportContext,
// TODO
playbackReportContext = null,
)
}
@@ -222,7 +241,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
existingIds: Set<UUID>,
count: Int,
): List<PlayableMedia> = withContext(Dispatchers.IO) {
runCatching {
// First try the online (Jellyfin API) path
val onlineResult = runCatching {
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
episodes.mapNotNull { episode ->
val id = episode.id ?: return@mapNotNull null
@@ -232,19 +252,37 @@ class DefaultPlayableMediaRepository @Inject constructor(
getPlayableMedia(id)
}
}.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load next-up items for $episodeId")
emptyList()
Timber.tag(TAG).w(error, "Unable to load next-up items from API for $episodeId")
null // Signal that we should try the offline fallback
}
if (onlineResult != null) return@withContext onlineResult
// Offline fallback: find next episodes from the local database
Timber.tag(TAG).d("Falling back to offline next-up resolution for $episodeId")
val currentEpisode = offlineMediaRepository.getEpisode(episodeId).first() ?: return@withContext emptyList()
val seriesEpisodes = offlineMediaManager.getEpisodesBySeries(currentEpisode.seriesId)
.sortedWith(compareBy({ it.seasonIndex }, { it.index }))
val currentIndex = seriesEpisodes.indexOfFirst { it.id == episodeId }
if (currentIndex == -1) return@withContext emptyList()
seriesEpisodes
.drop(currentIndex + 1)
.take(count)
.filter { it.id !in existingIds }
.mapNotNull { nextEpisode ->
getPlayableMedia(nextEpisode.id)
}
}
@OptIn(UnstableApi::class)
private fun createMediaItem(
mediaId: String,
playbackDecision: PlaybackDecision,
url: String,
title: String,
subtitle: String?,
artworkUrl: String,
playbackReportContext: PlaybackReportContext?,
playbackTag: Any?,
): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
@@ -252,20 +290,20 @@ class DefaultPlayableMediaRepository @Inject constructor(
.setArtworkUri(artworkUrl.toUri())
.build()
val builder = MediaItem.Builder()
.setUri(playbackDecision.url.toUri())
.setUri(url.toUri())
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setTag(playbackReportContext)
playbackCustomCacheKey(
mediaId = mediaId,
playbackUrl = playbackDecision.url,
playMethod = playbackDecision.reportContext.playMethod,
)?.let(builder::setCustomCacheKey)
.setTag(playbackTag)
return builder.build()
}
private fun PlaybackSource.toPlaybackMediaItemTag(): PlaybackMediaItemTag {
return PlaybackMediaItemTag(
playbackReportContext = playbackReportContext,
transcodingFallbackUrl = transcodingUrl,
)
}
private fun calculateResumePosition(
baseItem: BaseItemDto?,
mediaSource: MediaSourceInfo,
@@ -310,6 +348,39 @@ class DefaultPlayableMediaRepository @Inject constructor(
.build()
}
/**
* Calculates the resume position for offline media from the stored progress percentage
* and runtime string. Applies the same 5%-95% threshold logic as [calculateResumePosition].
*/
private fun calculateOfflineResumePosition(
progress: Double?,
runtime: String,
watched: Boolean,
): Long {
if (watched) return 0L
val progressPercent = progress ?: return 0L
if (progressPercent.isNaN() || progressPercent <= 0.0) return 0L
val runtimeMs = parseRuntimeToMs(runtime) ?: return 0L
if (runtimeMs <= 0L) return 0L
val positionMs = ((progressPercent / 100.0) * runtimeMs).toLong()
return if (progressPercent in 5.0..95.0) positionMs else 0L
}
/**
* Parses a runtime string in the format "2h 1m" or "25m" (produced by [formatRuntime])
* into milliseconds. Returns null for unparseable values like "—".
*/
private fun parseRuntimeToMs(runtime: String): Long? {
if (runtime.isBlank() || runtime == "") return null
val regex = Regex("""(?:(\d+)h\s*)?(\d+)m""")
val match = regex.matchEntire(runtime.trim()) ?: return null
val hours = match.groupValues[1].toLongOrNull() ?: 0L
val minutes = match.groupValues[2].toLongOrNull() ?: 0L
return TimeUnit.MINUTES.toMillis(hours * 60 + minutes)
}
private fun MediaSegmentDto.toMediaSegment(): MediaSegment {
val segmentType = when (type) {
INTRO -> SegmentType.INTRO

View File

@@ -4,13 +4,16 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackProgressReporter
import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect
import hu.bbara.purefin.data.jellyfin.download.JellyfinDownloadMediaSourceResolver
import hu.bbara.purefin.data.jellyfin.playback.SyncPlaybackPositionsHomeRefreshSideEffect
import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository
import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper
import javax.inject.Singleton
@@ -37,6 +40,12 @@ abstract class JellyfinBindingsModule {
@Binds
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
@Binds
@IntoSet
abstract fun bindSyncPlaybackPositionsHomeRefreshSideEffect(
impl: SyncPlaybackPositionsHomeRefreshSideEffect
): HomeRefreshSideEffect
@Binds
@Singleton
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor

View File

@@ -1,13 +1,17 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient
) : JellyfinMediaMetadataUpdater {
private val jellyfinApiClient: JellyfinApiClient,
private val localMediaRepository: LocalMediaRepository,
) : MediaMetadataUpdater {
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
if (watched) {
@@ -15,5 +19,22 @@ class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
} else {
jellyfinApiClient.markAsUnwatched(mediaId)
}
localMediaRepository.markAsWatched(mediaId, watched)
}
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
localMediaRepository.updateWatchProgress(mediaId, positionMs, durationMs)
}
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
localMediaRepository.updateWatchProgressPercent(mediaId, progressPercent)
}
override suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
) {
jellyfinApiClient.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks)
}
}

View File

@@ -4,11 +4,12 @@ import android.content.Context
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.QuickConnectSession
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.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -27,12 +28,13 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
@@ -45,7 +47,6 @@ import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
@@ -55,6 +56,7 @@ import org.jellyfin.sdk.model.api.PlaybackProgressInfo
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.UpdateUserItemDataDto
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
@@ -65,13 +67,14 @@ import javax.inject.Singleton
@Singleton
class JellyfinApiClient @Inject constructor(
@param:ApplicationContext private val applicationContext: Context,
@ApplicationContext private val applicationContext: Context,
private val userSessionRepository: UserSessionRepository,
private val networkMonitor: NetworkMonitor,
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
) {
private val jellyfin = createJellyfin {
context = applicationContext
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
apiClientFactory = okHttpFactory
}
private val api = jellyfin.createApi()
@@ -216,105 +219,271 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getLibraries") {
if (!ensureConfigured()) {
return@logRequest emptyList()
return@logRequest emptyList<BaseItemDto>()
}
try {
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
response.content.items
} catch (e: NotModifiedException) {
null
}
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
response.content.items
}
}
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun probeServer(): Boolean = withContext(Dispatchers.IO) {
logRequest("probeServer") {
if (!ensureConfigured()) {
return@logRequest false
}
api.systemApi.getPingSystem()
true
}
}
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getLibraryContent") {
if (!ensureConfigured()) {
return@logRequest emptyList()
return@logRequest emptyList<BaseItemDto>()
}
try {
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
enableImages = true,
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
response.content.items
} catch (e: NotModifiedException) {
null
}
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
enableImages = true,
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
response.content.items
}
}
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") {
if (!ensureConfigured()) {
return@logRequest emptyList()
return@logRequest emptyList<BaseItemDto>()
}
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
try {
val response = api.suggestionsApi.getSuggestions(
userId = userId,
mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = 8,
enableTotalRecordCount = true,
)
response.content.items
} catch (e: NotModifiedException) {
null
}
val userId = getUserId() ?: return@logRequest emptyList()
val response = api.suggestionsApi.getSuggestions(
userId = userId,
mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = 8,
enableTotalRecordCount = true,
)
response.content.items
}
}
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getContinueWatching") {
if (!ensureConfigured()) {
return@logRequest emptyList()
return@logRequest emptyList<BaseItemDto>()
}
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
try {
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items
} catch (e: NotModifiedException) {
null
}
val userId = getUserId() ?: return@logRequest emptyList()
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items
}
}
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getNextUpEpisodes") {
if (!ensureConfigured()) {
throw IllegalStateException("Not configured")
return@logRequest emptyList<BaseItemDto>()
}
try {
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items
} catch (e: NotModifiedException) {
null
}
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items
}
}
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getLatestFromLibrary") {
if (!ensureConfigured()) {
return@logRequest emptyList()
return@logRequest emptyList<BaseItemDto>()
}
try {
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 15,
)
response.content
} catch (e: NotModifiedException) {
null
}
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 15,
)
response.content
}
}
/**
* 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
}
}
}
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
logRequest("getItemInfo") {
if (!ensureConfigured()) {
@@ -388,6 +557,30 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun updatePlaybackPosition(
mediaId: UUID,
playbackPositionTicks: Long,
runtimeTicks: Long,
) = withContext(Dispatchers.IO) {
if (runtimeTicks <= 0L) return@withContext
val normalizedPlaybackPositionTicks = playbackPositionTicks.coerceIn(0L, runtimeTicks)
val isPastThreshold = normalizedPlaybackPositionTicks.toDouble() / runtimeTicks.toDouble() >= 0.8
logRequest("updatePlaybackPosition") {
if (!ensureConfigured()) {
return@logRequest
}
val result = api.itemsApi.updateItemUserData(
itemId = mediaId,
userId = getUserId(),
data = UpdateUserItemDataDto(
playbackPositionTicks = if (isPastThreshold) runtimeTicks else normalizedPlaybackPositionTicks,
played = isPastThreshold,
)
)
result.content
}
}
suspend fun markAsWatched(mediaId: UUID) = withContext(Dispatchers.IO) {
logRequest("markAsWatched") {
if (!ensureConfigured()) {
@@ -412,23 +605,6 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
logRequest("getMediaSources") {
if (!ensureConfigured()) {
return@logRequest emptyList()
}
val result = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
PlaybackInfoDto(
userId = getUserId(),
deviceProfile = null,
maxStreamingBitrate = 100_000_000,
),
)
result.content.mediaSources
}
}
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = withContext(Dispatchers.IO) {
logRequest("getMediaSegments") {
if (!ensureConfigured()) {
@@ -467,21 +643,11 @@ class JellyfinApiClient @Inject constructor(
}
fun getVideoStreamUrl(
itemId: UUID,
mediaSourceId: String?,
container: String? = null,
tag: String? = null,
playSessionId: String? = null,
liveStreamId: String? = null,
itemId: UUID
): String = try {
api.videosApi.getVideoStreamUrl(
itemId = itemId,
container = container,
mediaSourceId = mediaSourceId,
static = true,
tag = tag,
playSessionId = playSessionId,
liveStreamId = liveStreamId,
)
} catch (error: Exception) {
Timber.tag(TAG).e(error, "getVideoStreamUrl")
@@ -497,15 +663,6 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
logRequest("getPublicSystemInfoVersion") {
if (!ensureConfigured()) {
return@logRequest null
}
SystemApi(api).getPublicSystemInfo().content.version
}
}
suspend fun reportPlaybackStart(
itemId: UUID,
positionTicks: Long = 0L,
@@ -523,7 +680,6 @@ class JellyfinApiClient @Inject constructor(
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE,
@@ -551,7 +707,6 @@ class JellyfinApiClient @Inject constructor(
mediaSourceId = reportContext.mediaSourceId,
audioStreamIndex = reportContext.audioStreamIndex,
subtitleStreamIndex = reportContext.subtitleStreamIndex,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
repeatMode = RepeatMode.REPEAT_NONE,
@@ -573,7 +728,6 @@ class JellyfinApiClient @Inject constructor(
itemId = itemId,
positionTicks = positionTicks,
mediaSourceId = reportContext.mediaSourceId,
liveStreamId = reportContext.liveStreamId,
playSessionId = reportContext.playSessionId,
failed = false,
),
@@ -585,14 +739,37 @@ class JellyfinApiClient @Inject constructor(
val startedAt = SystemClock.elapsedRealtime()
return try {
val result = block()
networkMonitor.reportRequestSucceeded()
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Timber.tag(TAG).d("$functionName took ${elapsedMs}ms")
val sizeLog = when (result) {
is List<*> -> {
val items = result.size
if (items == 0) {
" (empty)"
} else {
val bytes = result.toString().toByteArray().size
val sizeStr = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes.toFloat() / (1024 * 1024))
}
" ($items items, $sizeStr)"
}
}
is Unit -> ""
else -> {
val bytes = result.toString().toByteArray().size
when {
bytes < 1024 -> " ($bytes B)"
bytes < 1024 * 1024 -> " (${bytes / 1024} KB)"
else -> " (%.1f MB)".format(bytes.toFloat() / (1024 * 1024))
}
}
}
Timber.tag(TAG).d("$functionName took ${elapsedMs}ms$sizeLog")
result
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
networkMonitor.reportRequestFailed(error)
val elapsedMs = SystemClock.elapsedRealtime() - startedAt
Timber.tag(TAG).e(error, "$functionName failed after ${elapsedMs}ms")
throw error
@@ -619,5 +796,9 @@ class JellyfinApiClient @Inject constructor(
companion object {
private const val TAG = "JellyfinApiClient"
// How many items to fetch on the head-only path. Two is enough to
// detect the common case of "first card unchanged" without making
// the head request meaningfully heavier than the minimum.
const val HEAD_LIMIT = 2
}
}

View File

@@ -3,7 +3,6 @@ package hu.bbara.purefin.data.jellyfin.download
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
import hu.bbara.purefin.core.data.EpisodeDownloadSource
import hu.bbara.purefin.core.data.MovieDownloadSource
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
@@ -11,7 +10,7 @@ import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.PlaybackSource
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
@@ -32,13 +31,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null
}
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(movieId) ?: return@withContext null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(movieId) ?: return@withContext null
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null
MovieDownloadSource(
movie = itemInfo.toMovie(serverUrl),
playbackUrl = playbackDecision.url,
customCacheKey = playbackDecision.downloadCustomCacheKey(movieId),
playbackUrl = playbackSource.directPlayUrl,
customCacheKey = playbackSource.downloadCustomCacheKey(movieId),
)
}
@@ -48,7 +47,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null
}
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(episodeId) ?: return@withContext null
val playbackSource = jellyfinPlaybackResolver.getPlaybackSource(episodeId) ?: return@withContext null
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl)
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
@@ -58,8 +57,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
episode = episode,
series = series,
season = season,
playbackUrl = playbackDecision.url,
customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId),
playbackUrl = playbackSource.directPlayUrl,
customCacheKey = playbackSource.downloadCustomCacheKey(episodeId),
)
}
@@ -91,14 +90,11 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
.map { it.id }
}
private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? {
if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) {
return null
}
private fun PlaybackSource.downloadCustomCacheKey(mediaId: UUID): String? {
return playbackCustomCacheKey(
mediaId = mediaId.toString(),
playbackUrl = url,
playMethod = reportContext.playMethod,
playbackUrl = directPlayUrl,
playMethod = playbackReportContext.playMethod,
)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
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")

Some files were not shown because too many files have changed in this diff Show More