Compare commits

...

389 Commits

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

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

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

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

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

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

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

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

Drive the slider from sliderPosition unconditionally, sync sliderPosition
from the prop via a LaunchedEffect only when not scrubbing, and pin
sliderPosition to the committed target before clearing isScrubbing in
onValueChangeFinished.
2026-06-21 11:17:16 +00:00
67a141e7d7 chore(build): increment app versionCode to 1000031 2026-06-21 10:25:20 +02:00
9f432edd27 chore(build): increment app-tv versionCode to 2000015 2026-06-20 21:50:31 +02:00
80b1bd8547 fix(data): gracefully skip unsupported library types during refresh
Previously, toLibrary() threw UnsupportedOperationException for
library types the app does not surface (e.g. boxsets, music, photos,
live TV), which aborted the entire home screen refresh. Now these
unsupported types are logged and skipped, preventing a single
unexpected view from leaving the user with an empty home screen.
2026-06-20 21:50:15 +02:00
00c5689195 fix(tv-player): Do not show hidden seek line when skip button is shown 2026-06-20 21:12:13 +02:00
bd2c1777a5 fix(tv-player): Only show paused feedback 2026-06-20 21:12:12 +02:00
a5d04998ac refactor(tv): extract hero backdrop and simplify gradient drawing
Split the hero backdrop (image + gradient overlays) into TvHomeHeroBackdrop, rendered behind the home content. Replace nested Box/background gradient approach with drawWithContent for better composability. Clean up unused imports across TvHomeScreen, TvFocusedItemHero, and TvHomeContent.
2026-06-20 18:52:09 +00:00
da89a8a661 fix(tv): adjust hero height fraction for better layout on TV screens 2026-06-20 20:22:49 +02:00
53a7d4ad23 chore(build): increment app versionCode to 1000030 2026-06-20 19:42:19 +02:00
8ac0fd0bd1 feat(series): show season/episode context on play button and truncate subtitle
Enhance the series play button to display season and episode number alongside the play label when a next-up episode is available. Also constrain the subtitle text to a single line with ellipsis overflow to prevent layout issues from long subtitle strings.
2026-06-20 17:42:03 +00:00
1910a0c144 refactor(player): improve EmptyValueTimedVisibility and ValueChangeTimedVisibility components 2026-06-20 19:26:59 +02:00
217ba5b6d9 refactor(player): use mutableFloatStateOf for volume and brightness states 2026-06-20 18:38:59 +02:00
fee0e703eb chore(build): increment app versionCode to 1000029 2026-06-20 18:18:49 +02:00
0fc4644fdb refactor(tvplayer): remove unused isPlaying parameter from key event handler 2026-06-20 18:04:28 +02:00
6e48b35741 refactor(data/jellyfin): reload media items on user data change events 2026-06-20 16:01:22 +00:00
8d5948438e feat(data/jellyfin): sync watch progress in real time via WebSocket
Add JellyfinWebSocketService that subscribes to UserDataChangedMessage events and propagates watch progress changes to the local media repository.

The WebSocket subscription is gated by login status and network connectivity (matching other background work), runs with automatic retry on transient failures, and follows the lifecycle of the Jellyfin SDK's ApiClient for automatic reconnection.

Inject the service into JellyfinSessionBootstrapper to ensure Hilt instantiates the singleton at app startup.
2026-06-20 13:27:10 +00:00
8a2702a7cb refactor(core/series): improve data loading reliability with concurrent initialization 2026-06-20 12:18:29 +00:00
5a98f3d2e7 refactor(core): separate data loading from repository reads
Move implicit loading side effects out of getX methods into explicit loadX methods, making data flow predictable. Remove seriesTitle StateFlow in favor of direct seriesName access. Add proper error handling to repository load operations.
2026-06-20 09:27:25 +00:00
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
ef56db5d16 feat: redesign settings screen 2026-05-23 12:55:37 +00:00
Barnabás Balogh
d81c382ba5 Merge pull request #3 from bbara04/feat/debug-build-icon-update-url
feat: add red debug icon and separate debug update URL
2026-05-23 11:56:08 +02:00
6e90a9e225 feat(build): update debug and release update URLs in README and build.gradle.kts 2026-05-23 11:52:04 +02:00
8d73974add feat(build): add separate versionCode for debug build type
Renamed purefinVersionCode to purefinReleaseVersionCode and added purefinDebugVersionCode to allow independent version tracking for debug and release builds.
2026-05-23 09:31:47 +00:00
Purefin Dev
2e4eb2fb30 feat: add red debug icon and separate debug update URL
- Replace hardcoded vector fill color with @color/ic_launcher_foreground
- Override foreground to #E53935 (red) for debug builds
- Use manifest placeholders for build-type specific update URLs
- Update release URL to https://apks.t.bbara.hu/apks/purefin-app/update.json
- Add APK repository upload guide to README
2026-05-23 08:50:25 +00:00
4c15474ad9 feat(build): add debug build type with applicationId and version name suffixes 2026-05-22 21:17:20 +02:00
bbec9c1e06 chore(build): increment app versionCode to 1000018 2026-05-22 21:17:20 +02:00
Barnabás Balogh
e82e370e48 Merge pull request #2 from bbara04/feat/brightness-auto-mode
feat/brightness-auto-mode
2026-05-22 18:52:44 +02:00
41ae5f939d Merge remote-tracking branch 'origin/feat/brightness-auto-mode' into feat/brightness-auto-mode
# Conflicts:
#	app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt
2026-05-22 18:50:45 +02:00
Purefin Dev
d14eec2346 feat(player): add adaptive brightness mode support
Extend the brightness slider to allow values below 0% which triggers
adaptive/auto brightness mode (screenBrightness = -1.0f). The brightness
indicator shows 'Auto' with an empty bar when in adaptive mode.

- Widen gesture range from [0,1] to [-0.1,1] so a small flick below 0 enters auto mode
- readCurrentBrightness returns -0.1f instead of 0.5f when system auto is active
- applyBrightness writes -1f to LayoutParams when value < 0
- PlayerAdjustmentIndicator displays 'Auto' in tertiary color for negative values
2026-05-22 16:46:22 +00:00
Purefin Dev
70a587b743 feat(player): add adaptive brightness mode support
Extend the brightness slider to allow values below 0% which triggers
adaptive/auto brightness mode (screenBrightness = -1.0f). The brightness
indicator shows 'Auto' with an empty bar when in adaptive mode.

- Widen gesture range from [0,1] to [-1,1] so dragging below 0 enters auto mode
- readCurrentBrightness returns -1f instead of 0.5f when system auto is active
- applyBrightness writes -1f to LayoutParams when value < 0
- PlayerAdjustmentIndicator displays 'Auto' in tertiary color for negative values
2026-05-22 16:41:58 +00:00
56da98a90b refactor(settings): remove Settings text 2026-05-22 13:42:42 +02:00
Barnabás Balogh
5dabc8bbf0 Merge pull request #1 from bbara04/todos
Refactor: replace collectAsState with lifecycle-aware variant & add per-library visibility toggles
2026-05-22 13:34:38 +02:00
Purefin Dev
8da759257c refactor: replace collectAsState with lifecycle-aware collectAsStateWithLifecycle
Migrate all Flow state collection to collectAsStateWithLifecycle for proper lifecycle awareness, ensuring collection stops when the composable is not in the STARTED state.

- Added lifecycle-runtime-compose dependency to app and app-tv modules
- Replaced all collectAsState() calls with collectAsStateWithLifecycle()
- Updated initial parameter to initialValue for the lifecycle-aware variant
2026-05-21 19:47:58 +00:00
Purefin Dev
645e28ab30 feat(settings): add per-library visibility toggles for home screen
Add a Home Screen Libraries setting group with per-library toggles to control which libraries appear on the home screen. Libraries toggled off are hidden from both phone and TV home screens but remain accessible via the Libraries tab and navigation drawer.
2026-05-21 19:38:15 +00:00
1274ab25f9 feat(app): expose test tags for Appium 2026-05-21 10:10:08 +02:00
56a2b42434 feat(network): enhance connectivity monitoring with request reporting 2026-05-20 17:07:32 +02:00
069f29c1c8 chore(build): increment app-tv versionCode to 2000009 2026-05-16 17:42:20 +02:00
f7b266ec8c fix(docs): remove unnecessary blank line in AGENTS.md 2026-05-16 13:49:39 +02:00
fcf7c435be chore(gitignore): add .opencode to ignore list 2026-05-16 13:42:48 +02:00
aa884994c2 chore(build): increment app versionCode to 1000017 2026-05-14 23:09:05 +02:00
7ae0c2eb27 feat(api): increase item limit for library queries from 10 to 15 2026-05-14 21:45:06 +02:00
b7186be46a feat(player): enhance video aspect ratio handling and resize mode logic 2026-05-14 21:44:50 +02:00
deacb89adb chore(build): increment app versionCode to 1000016 2026-05-14 18:30:16 +02:00
e09b63f47e refactor(logging): standardize logRequest function naming and remove redundant logging 2026-05-14 18:29:58 +02:00
1fc2c8cf05 feat(logging): improve uncaught exception handling with dedicated logging method 2026-05-14 18:24:56 +02:00
c70657e627 feat(logging): enhance log entry handling with truncation and improve upload file naming 2026-05-14 18:23:21 +02:00
063f2fd5ca chore(build): increment app versionCode to 1000015 2026-05-14 18:07:48 +02:00
4b87f93dee Add Timber file logging and Jellyfin log upload 2026-05-14 18:06:54 +02:00
f95fbf72b3 chore(build): increment app versionCode to 1000014 2026-05-14 14:38:40 +02:00
6cff6be238 feat: enhance media playback handling with offline support and download metadata integration 2026-05-14 14:38:24 +02:00
a0a51ef436 feat: add smart download management with enable and delete options 2026-05-14 14:09:35 +02:00
f60aba5a72 feat: update media selection logic to handle offline state in view models and DTOs 2026-05-14 12:56:48 +02:00
83730188eb feat: add online status handling to navigation and UI components 2026-05-14 12:39:20 +02:00
a92c10a07c chore(build): increment app versionCode to 1000013 2026-05-13 23:07:12 +02:00
657049edc9 chore(build): increment app-tv versionCode to 2000008 2026-05-13 23:05:34 +02:00
70f086b5a1 feat: enhance player UI with hidden seek timeline and time display improvements 2026-05-13 23:05:10 +02:00
cc643ae34f feat: add preferenceMediaId to PlayableMedia and update related logic to make more prone to mistakes 2026-05-13 22:53:27 +02:00
9fb3672c19 chore(build): update versionCode to use properties from gradle.properties 2026-05-13 22:37:25 +02:00
6a5f2fd593 chore(build): increment app-tv versionCode to 2000007 2026-05-13 22:23:18 +02:00
8950a37341 feat: update app to check for updates on app open 2026-05-13 22:22:53 +02:00
7ec9a0f41f feat: implement logout functionality and settings provider 2026-05-13 22:17:50 +02:00
d3b66d4fc5 feat: redesign login screen on both app and app-tv 2026-05-13 21:59:23 +02:00
e69a3a6d4a feat: redesign login screen on both app and app-tv 2026-05-13 21:16:48 +02:00
dbcabd0848 refactor: navigation drawer to use state management and auto-close on item selection 2026-05-13 21:00:22 +02:00
beae723372 feat: add ReadOnlySetting and corresponding UI component for displaying read-only settings 2026-05-13 19:31:58 +02:00
147fabd895 chore(build): increment app versionCode to 1000012 2026-05-13 19:23:23 +02:00
56aa16755d refactor: simplify layout in EpisodeComponents and remove unused modifiers 2026-05-13 19:19:51 +02:00
f5d476b28e feat: increment version code to 2000006 2026-05-13 19:14:07 +02:00
2f6d7e77c3 refactor: use common DefaultTopBar component in all components 2026-05-13 17:52:30 +02:00
fbb19e6970 refactor: extract common MediaComponents screen layout skeleton into its own component. 2026-05-13 17:33:46 +02:00
58d418ff65 feat: increment version code to 1000011 for app updates 2026-05-13 17:04:58 +02:00
3ee4b558f5 Make media detail flows reactive 2026-05-13 16:43:42 +02:00
406d0f4ed8 feat(tvseries): improve focus management for season and episode selection 2026-05-09 09:03:07 +02:00
4e9d8c0b3b feat(player): enhance UI responsiveness with animated color states for buttons and track selection 2026-05-09 08:49:49 +02:00
4e322e7723 feat(player): pause playback on lifecycle stop event for improved resource management 2026-05-09 08:29:27 +02:00
2c0d689767 feat: increment version codes for app updates 2026-05-08 22:36:28 +02:00
26a134869e feat(settings): update settings items to handle null values for improved stability 2026-05-08 22:35:36 +02:00
80f5454afb feat(settings): add app update settings action 2026-05-08 22:29:07 +02:00
60d2a1eb2a feat: trigger refresh on home navigation in TvAppScreen for improved data consistency 2026-05-08 20:26:26 +02:00
0554f328c6 feat: trigger refresh on home navigation in TvAppScreen for improved data consistency 2026-05-08 20:20:07 +02:00
af2e8b0dc9 feat: enhance settings screen with dropdown and void setting options for improved user experience 2026-05-08 20:19:05 +02:00
e05041fd14 feat: add focus restoration to TabRow in TvSeriesComponents for improved accessibility 2026-05-08 19:46:46 +02:00
9313cded53 feat: enhance TvSeriesScreen to support focused season and episode loading 2026-05-08 19:44:49 +02:00
4788eec102 feat: add SeriesIdLookUpUseCase for episode to series ID mapping and update track preference saving logic 2026-05-08 19:30:44 +02:00
5444d3c8b1 feat: refactor logging in JellyfinApiClient to improve request tracking and error handling 2026-05-08 18:53:18 +02:00
9d7c166bc8 feat: refactor SeriesScreen and ViewModel for improved season episode loading and download handling 2026-05-08 18:47:15 +02:00
dddf6e4cfa feat: improve TvNavigationDrawer styling with color and font size adjustments 2026-05-07 20:24:44 +02:00
7a04c59023 feat: increment version codes for application updates 2026-05-07 20:21:57 +02:00
3384a6795b feat: enhance TvNavigationDrawer with focus handling and accessibility improvements 2026-05-07 20:21:44 +02:00
dc5c324962 feat: add UnwatchedEpisodeBadge and remove 'started' parameter from WatchStateBadge 2026-05-07 19:51:28 +02:00
56fe574940 feat: add watched status to MediaUiModel 2026-05-07 19:45:09 +02:00
607bfca0c0 Revert "feat: filter watched items from the Continue Watching section"
This reverts commit 41f7129b08.
2026-05-07 19:32:05 +02:00
f75f18d2af refactor: restructure SettingsRepository and implement SettingsRepositoryImpl 2026-05-06 17:36:34 +02:00
322eb69ab5 refactor: update package structure to use core namespace 2026-05-06 17:24:04 +02:00
41f7129b08 feat: filter watched items from the Continue Watching section 2026-05-04 18:33:58 +02:00
ca3e5dfdf5 delete: Remove genre loading and storage from LocalMediaRepository 2026-05-04 18:17:51 +02:00
bc4b26291f refactor: MediaProgressWriter to LocalMediaUpdater and add markAsWatched support 2026-05-04 18:15:29 +02:00
ed80948676 refactor(MediaImageCard): adjust column modifier to always show MoreVert IconButton 2026-05-04 18:06:55 +02:00
9a83eb635f chore(build): increment versionCode to 1000008 2026-05-03 22:12:05 +02:00
31c1b0dd92 feat(MediaImageCard): add dropdown menu for marking media as watched/unwatched 2026-05-03 22:09:46 +02:00
f2c46af410 feat(jellyfin): add functionality to mark media as watched/unwatched 2026-05-03 22:08:56 +02:00
b1ae478f6d refactor(SearchScreen): rename SearchFullScreenContent to SearchScreenContent for clarity 2026-05-03 18:00:52 +02:00
132c6feee0 feat(DefaultTopBar): reduce logo size for better layout consistency 2026-05-03 17:56:37 +02:00
9953c94e86 feat(logo): update logo dimensions and scaling for improved display 2026-05-03 17:56:31 +02:00
db15bf7e2c chore(build): increment versionCode to 1000006 for the next release 2026-05-03 17:45:38 +02:00
63a8a108e2 feat(settings): add TvSettingsScreen and integrate settings management 2026-05-03 17:37:48 +02:00
5d12b97922 chore(build): update versionCode to 2000003 for the upcoming release 2026-05-03 15:46:51 +02:00
fe0b76a0ad feat(tv-update): implement update check and dialog for available updates on android tv devices 2026-05-03 14:49:19 +02:00
a5bb1f84dc feat(settings): add SettingsScreen and integrate settings management 2026-05-03 14:32:19 +02:00
f925ca57ea feat(settings): add settings management with DataStore integration 2026-05-03 14:16:37 +02:00
a41836b925 chore(build): update versionCode to 1000005 for the upcoming release 2026-05-03 14:04:13 +02:00
5bec125f56 refactor(app): refactor update logic to make core logic more reusable by moving it into the core.feature.update package 2026-05-03 14:00:04 +02:00
3a748397f8 feat(update): implement in-app update check and installation process 2026-05-03 13:39:27 +02:00
ae4c2b34f4 feat(search): update genre selection to support multiple selections in SearchScreen 2026-05-02 21:38:56 +02:00
f957098369 feat(search): update genre selection to support multiple selections in SearchScreen 2026-05-02 21:25:32 +02:00
8bf3f08df0 feat(search): implement back navigation in SearchScreen and SearchViewModel 2026-05-02 21:12:54 +02:00
efd7593646 feat(topbar): create DefaultTopBarIconButton for reusable icon button implementation 2026-05-02 21:04:58 +02:00
0edac36ca8 feat(search): rename SearchFullScreen to SearchScreen for consistency and clarity 2026-05-02 20:56:54 +02:00
53bc6246e4 feat(libraries): add size property to Library and related models for better content representation 2026-05-02 20:56:05 +02:00
2753a45645 feat(card): introduce MediaImageCard for consistent media display across components 2026-05-02 20:47:35 +02:00
fbcba6fbfc feat(libraries): refactor LibraryListItem and LibraryScreen for improved click handling and layout 2026-05-02 20:28:54 +02:00
dbac1b56c5 feat(topbar): enhance DefaultTopBar to support left and right actions for improved layout flexibility 2026-05-02 20:22:59 +02:00
1f0c192460 feat(colors): update color values for improved contrast and visibility 2026-05-02 19:51:13 +02:00
a0b573cbfe feat(sectionheader): update header color and button text color for improved visibility 2026-05-02 19:48:21 +02:00
1a2687f1a2 feat(topbar): refactor DefaultTopBar to use Surface and improve layout structure 2026-05-02 19:47:33 +02:00
803edcb80c feat(logo): increase logo size for better visibility in top bar and search screen 2026-05-02 19:35:53 +02:00
e55bd320d1 clean(app): delete unused asset files 2026-05-02 19:31:42 +02:00
fe1d449fe0 feat(branding): update app icons and logo for improved visual consistency 2026-05-02 19:28:47 +02:00
f626c9533e refactor: update color definitions for dark theme consistency 2026-05-02 19:06:05 +02:00
6433225014 feat(search): add Jellyfin-backed search 2026-05-02 17:40:56 +02:00
36e89f35d9 refactor: rename media repository files for consistency 2026-05-02 16:53:48 +02:00
217e8df417 refactor: merge media catalog reader into repository 2026-05-02 16:51:55 +02:00
672d8e1d64 feat(search): wire full-screen search with genre browsing 2026-05-02 15:17:06 +02:00
c6fe84654f refactor(search): simplify search component names 2026-05-02 14:20:20 +02:00
0f0c86dcf9 feat(app): integrate search button into top bars and refactor for reusability 2026-05-02 13:15:36 +02:00
557679861a feat(app): add shared bounds key functionality for home media navigation 2026-05-02 13:10:34 +02:00
64b1032b74 feat(app): implement shared transition for search functionality in HomeTopBar and HomeSearchFullScreen 2026-05-02 13:06:21 +02:00
44219d00d8 feat(app): add HomeSearchFullScreen route and integrate search functionality in HomeScreen 2026-05-02 12:26:33 +02:00
cff0b60b31 refactor(player): replace PurefinTextButton with SkipSegmentButton for improved UI consistency 2026-05-02 11:57:42 +02:00
9610124af0 refactor(app): move the skip button to the PlayerControlsOverlay panel 2026-05-02 11:52:36 +02:00
185a0c8a85 fix(app): adjust icon size in CircularIconButton for better layout 2026-05-02 11:46:59 +02:00
dfaf1079ac fix(app): add spacing between sections in home content layout 2026-05-02 11:42:46 +02:00
65693a9220 fix(tv): update TvIconButton size and refactor layout for consistency 2026-05-02 11:13:15 +02:00
58087bb4a5 fix(tv): format secondary text for episode display 2026-05-02 10:20:17 +02:00
71a9a8b98e fix(tv): align home section spacing 2026-05-01 22:06:37 +02:00
f304b1f1f5 feat(tv): add seriesName and seasonIndex to Episode data model 2026-05-01 21:28:33 +02:00
e738e9637f fix(tv): open selected episode in series screen 2026-05-01 20:59:50 +02:00
bfc007f24f feat(player): batch relative seek requests 2026-05-01 20:06:21 +02:00
d815812e79 refactor(tv): move skip button to the TvPlayerControlsOverlay panel 2026-05-01 19:59:01 +02:00
b0baad7f8c refactor(player): remove unused setControlsAutoHideDelay method from PlayerViewModel 2026-05-01 19:40:57 +02:00
3b95185f98 fix(tv): streamline focus management and improve component structure in TV sections 2026-05-01 19:36:14 +02:00
72fea00444 feat: integrate Firebase Crashlytics and Google Services into the project 2026-05-01 17:35:29 +02:00
8f5d0c5bc1 chore: add *.patch to .gitignore to exclude patch files from version control 2026-05-01 17:27:55 +02:00
6341510cc2 refactor(tv): replace custom season tab implementation with TabRow for improved UI consistency 2026-05-01 13:06:01 +02:00
2ff6fe997e refactor(tv): improve focus management and clean up unused focus requesters 2026-05-01 12:54:44 +02:00
7401f986c6 fix(tv): focus segment skip button 2026-05-01 12:29:01 +02:00
93070ba23f refactor: update IconButton colors in the top bars for improved UI consistency 2026-05-01 11:44:14 +02:00
56c4991467 refactor: update MediaActionButton styles for improved consistency 2026-05-01 11:36:03 +02:00
7c38d22717 refactor: replace Surface with Card components for improved UI consistency 2026-05-01 10:40:45 +02:00
9f480659ec docs: add UI principles and guidelines for component design 2026-05-01 10:34:19 +02:00
3c16817442 fix(tv): focus next up episode in series details 2026-04-30 21:18:43 +02:00
c4eed9ece3 fix(tv): open episodes in SeriesScreen view 2026-04-30 20:08:32 +02:00
f444a5e09f fix(tv): align detail screen sizing 2026-04-29 23:37:23 +02:00
522b93ff06 refactor: Enhance error handling in API calls with logging 2026-04-29 23:07:48 +02:00
1fc291d2d8 refactor: Use default Column instead of LayyColumn 2026-04-29 22:51:56 +02:00
84a95b0af6 fix(tv): stabilize navigation drawer animation 2026-04-29 20:09:00 +02:00
8a7577d5bb docs: add AGENTS.md with guidelines for code contributions 2026-04-29 18:39:56 +02:00
22c414b5e2 Revert "refactor: remove unused name field from LibraryDto"
This reverts commit 8a1d5ccfab.
2026-04-29 18:09:13 +02:00
3d01f0faf3 feat: Added getGenre api wrapper function 2026-04-29 18:04:52 +02:00
3e3d99f1f2 feat: added HomeSearchFullScreen as a starting point for search screen 2026-04-29 18:04:26 +02:00
790474d992 refactor: remove unused HomeModels 2026-04-29 17:45:41 +02:00
8a1d5ccfab refactor: remove unused name field from LibraryDto 2026-04-29 17:44:20 +02:00
72671d9ac3 refactor: remove unused HomeModels components 2026-04-29 17:42:25 +02:00
5595a15820 refactor: replace HomeNavItem with LibraryUiModel in LibrariesScreen and related components 2026-04-29 17:40:51 +02:00
bd5e43114b refactor: extract and rename LibraryUiModel 2026-04-29 17:35:52 +02:00
8cc85d1525 refactor: replace slide transitions with fade transitions in AppScreen 2026-04-29 16:40:12 +02:00
4c986432f6 refactor: extract common part from the HomeTopBar and make component logic reusable 2026-04-27 21:11:25 +02:00
afdd9b860f feat: integrate dynamic library navigation in TV drawer
Refactors the TV navigation drawer to use specific library routes instead
of a static libraries overview. Added a `NavDisplay` with a backstack to
manage navigation between the Home screen and individual Library screens
directly from the drawer.

- Replace `TvDrawerDestination` enum with `Route` in the navigation drawer
- Dynamically populate drawer items based on available libraries
- Add `NavDisplay` to `TvAppScreen` for nested TV navigation
- Connect `TvLibraryScreen` to `AppViewModel` for media selection handling
- Remove back button from `TvLibraryScreen` in favor of drawer navigation
- Update `TvNavigationDrawerTest` to reflect routing changes
2026-04-26 18:08:40 +02:00
51ffd14804 refactor: reduce maxLines for primary text in HomeBrowseCard 2026-04-26 14:53:57 +02:00
f086956578 feat: enhance track label formatting for audio, subtitles, and video 2026-04-26 13:15:17 +02:00
01de2ca19c feat: move subtitles when controls overlay are shown 2026-04-26 12:47:28 +02:00
522c8273d5 feat: add skip segment functionality to player 2026-04-26 12:21:52 +02:00
9906d473c5 refactor: reduce maxLines for improved text display in PlayerQueuePanel 2026-04-25 22:45:36 +02:00
1b8300ee1d refactor: streamline playback handling by removing redundant seekTo call 2026-04-25 22:43:51 +02:00
aa58cf3aff refactor: optimize playlist queue updates by combining flows for current playable media 2026-04-25 22:35:58 +02:00
81a3504606 refactor: update media retrieval to use Flow and improve error handling in PlaylistQueue loading 2026-04-25 22:31:39 +02:00
b15453ab01 refactor: convert PlayableMedia to sealed class with specific media types 2026-04-25 21:25:11 +02:00
f9eaf0cf84 refactor: update artwork URL construction in PlaylistElementUiModel 2026-04-25 20:46:14 +02:00
78a5f716c0 refactor: refine TV detail cast rows and player queue behavior
Temporarily hide cast rows on TV movie, episode, and series detail surfaces while preserving the surrounding section layout.

Simplify TV player queue rows by removing the secondary subtitle line so queued items scan more cleanly.

Increase the next-up queue fetch from 5 to 10 items and skip missing playlist episodes with an error log instead of throwing during queue mapping.
2026-04-25 18:43:14 +02:00
f7f9fc1058 refactor: centralize playable media state
Move playback-related media state into shared model types and make the player consume a single PlayableMedia object instead of separately loading MediaItems, track preferences, resume positions, and media segments.

This updates PlayableMediaRepository to return playable media bundles, seeds the player playlist with the current item, keeps next-up entries in the same model, restores resume/segment handling, and fixes current queue highlighting.

The change also moves track preference models into core-model, centralizes Jellyfin BaseItemDto conversion, removes placeholder movie audio/subtitle fields, bumps the offline Room schema, and updates mobile/TV queue UI code for the playlist model.

Verification:
./gradlew --no-daemon :core-model:compileDebugKotlin :core:compileDebugKotlin :data:compileDebugKotlin :app:compileDebugKotlin
2026-04-25 18:08:23 +02:00
2433ececac refactor: change dependency structure. core should not depend on data. Move repository interfaces to core. Move hilt bindings in the data model. 2026-04-24 19:02:26 +02:00
354314710b refactor: extract core-model into its own module 2026-04-24 18:11:28 +02:00
ab7700d202 refactor: rename ui-common module to core-ui module 2026-04-24 17:43:33 +02:00
7232352014 refactor: rename domain module to core module 2026-04-24 17:42:18 +02:00
57e2983527 refactor: move JellyfinAuthInterceptor and JellyfinNetworkModule to the domain module 2026-04-24 09:36:19 +02:00
a1239c907a refactor: reorganize package structure and remove unused files (remove old core parent package) in domain module 2026-04-24 09:31:30 +02:00
a9bd23e26c refactor: moved CompositeMediaRepository to the domain level + changes in connected classes 2026-04-24 09:04:51 +02:00
8423e63937 refactor: moved packages from data/core into /data in the data module 2026-04-24 08:37:45 +02:00
3f7c1966af Collapse shared modules into data domain and ui-common 2026-04-23 20:42:59 +02:00
59a471c535 feature: Added MediaSegmentManager 2026-04-23 19:59:12 +02:00
f0a4aadcf0 Derive player state from Media3 events 2026-04-22 20:04:47 +02:00
65f2bd811b refactor: Update MediaTrackPreferences handling for improved null safety and initialization 2026-04-22 19:39:24 +02:00
899e2f08b0 refactor: Added MediaSegments to MediaContext to make it accesible during playback. Also refactored MediaTrackPreferences to load preference in the PlayerViewModel 2026-04-22 19:34:11 +02:00
188efde982 Refactor JellyfinApiClient into thin API wrapper 2026-04-22 18:58:15 +02:00
3ae1425eba refactor: Add lazy list state to sections for automatic scrolling to top 2026-04-22 18:31:57 +02:00
7f83bb181b refactor: Include media data in HomeCache so the first startup is faster. 2026-04-21 22:24:03 +02:00
a2931b78f9 refactor: simplify AppContentRepository initialization logic. 2026-04-21 21:34:22 +02:00
d0e5e978be Refactor app navigation to use Navigation3
Integrate `navigation3` into `AppScreen` to manage tab transitions and
backstack state. Replaced manual tab state management with `NavDisplay`
and `NavBackStack`, and added horizontal slide animations for switching
between Home, Libraries, and Downloads screens.
2026-04-20 20:23:40 +02:00
a39581682f Refactor media selection logic to use a unified onMediaSelected callback
Consolidates specific media selection callbacks (`onMovieSelected`, `onSeriesSelected`, `onEpisodeSelected`) into a single `onMediaSelected` handler that accepts a `MediaUiModel`. This simplifies the interface between the ViewModel and UI components across both mobile and TV screens, moving the type-checking logic into `AppViewModel`.
2026-04-20 20:08:38 +02:00
a9b0f771f8 refactor: Use MediaUiModel instead of SuggestedItem 2026-04-20 19:51:13 +02:00
18e352eb1e refactor: change background color of PlayerScreen to black 2026-04-20 18:13:46 +02:00
9545e4d619 refactor: remove forced common PosterCardContent and inline them in the corresponding app ui module 2026-04-20 18:13:25 +02:00
920b0e0e4a Refactor TV home media cards smaller 2026-04-19 20:35:20 +02:00
2c5585417a refactor: add backdropImageUrl to MediaUiModel for improved image handling 2026-04-19 20:14:19 +02:00
a0086c6f62 refactor: rename imageUrl to primaryImageUrl for consistency across components 2026-04-19 20:08:41 +02:00
52a51b1b28 Tighten TV home hero and bring-in-view offset 2026-04-19 20:07:08 +02:00
76eebc1139 refactor: remove overcomplicated TvHomeHeroState 2026-04-19 19:49:12 +02:00
632dda52d6 Refactor media models to use MediaUiModel for improved consistency and type safety 2026-04-18 21:35:56 +02:00
5a377fc428 Add keys to lazy Compose entries 2026-04-17 18:02:22 +02:00
858f599dbd Upgrade AGP to 9.1.1 2026-04-17 17:36:22 +02:00
18844fcd38 Remove Play Store logo assets 2026-04-15 21:23:47 +02:00
9591b72273 Extract shared Compose UI into core module 2026-04-15 21:21:10 +02:00
29747a1031 Reorganize app-tv packages and align test imports 2026-04-15 20:35:46 +02:00
ad930e899f feat: introduce core data models and interfaces for playback and downloads 2026-04-15 20:02:50 +02:00
b67c8076f1 refactor: update package structure and imports for consistency across UI components 2026-04-14 22:03:36 +02:00
1e6c0e838e refactor: reorganize package structure for episode, movie, and series screens 2026-04-14 19:57:06 +02:00
1f6e3a8b43 feat: update adaptive icons to use color resources and add monochrome variant 2026-04-14 19:52:15 +02:00
3509a09064 Separate mobile downloads from TV playback 2026-04-13 16:31:57 +02:00
9a130e9738 Keep TV player controls hidden while seeking 2026-04-13 14:40:07 +02:00
a0af187e8f feat: implement quick play/pause toggle for TV player
Adds the ability to play and pause media using the Center or Enter keys
without displaying the full player controls. When pausing via this
method, a brief visual "Pause" feedback overlay is shown in the center
of the screen.

- Added `TvPlayerHiddenStopFeedback` component and related logic
- Updated `handleTvPlayerRootKeyEvent` to support background playback toggling
- Added `pausePlayback` and `resumePlayback` to `PlayerViewModel` and `PlayerManager`
- Included comprehensive UI tests for the new hidden control behavior
2026-04-12 19:02:45 +02:00
16839942b1 Focus play/pause button when player controls open 2026-04-12 18:39:57 +02:00
360cd51e23 feat: Enhance TV player controls with key event handling and overlay visibility management 2026-04-10 15:49:34 +02:00
7c30e12af7 Fix TV player track selector focus 2026-04-09 19:59:01 +02:00
53f7cb58f3 feat: Update background image URL handling in TvEpisodeScreen 2026-04-09 19:28:17 +02:00
313a971684 feat: Rename screen and component classes for TV context 2026-04-09 19:01:16 +02:00
512f7bbb5b feat: Enable ASS direct play in TV device profile 2026-04-09 18:54:56 +02:00
58980f4ac7 feat: Update episode retrieval counts in API client and media repository 2026-04-09 18:45:21 +02:00
d007c80af7 Refine TV player inline playlist flow
Move the TV player queue into a simplified bottom inline playlist row and keep the existing seek bar -> controls -> playlist navigation hierarchy.

Focus playlist entry on the current queue item, keep focus pinned on the row at the horizontal edges, and return to the controls row on Up/Back.

Add compose coverage for playlist expansion, current-item focus, fallback focus, and row edge navigation.
2026-04-09 18:22:21 +02:00
54e79d8bee feat: Extend play toggle functionality to include additional enter key inputs 2026-04-09 17:03:16 +02:00
4ef78b8ec6 feat: Implement back button behavior for TV player controls 2026-04-09 16:57:30 +02:00
d253a6c8d0 feat: Add HLS support to ExoPlayer implementation 2026-04-09 14:03:53 +02:00
b41a9c24a2 feat: Implement auto-hide delay for TV player controls 2026-04-08 20:19:04 +02:00
98c936aea6 feat: Allow repeated seeking in the tv ui 2026-04-08 20:02:27 +02:00
cf08db5034 Split mobile and TV playback profiles 2026-04-08 19:46:37 +02:00
1a17c52326 refactor: Throw out overcomplicated shit code for Playback 2026-04-08 18:03:23 +02:00
674e0881d7 code cleanup 2026-04-07 21:28:08 +02:00
b47ebda8e6 feat: add TV player UI components including controls, loading error card, and track selection 2026-04-07 19:25:23 +02:00
bc41116118 fix: adjust buffer durations and prioritize time over size in video player module 2026-04-07 19:25:03 +02:00
fb1d099ec1 refactor: remove unused back navigation handlers in TV player UI 2026-04-07 18:53:42 +02:00
bcbad6f262 Refine TV player remote control key mappings
Update directional key behavior to separate seeking, showing controls, and
toggling playback. Left/Right now seek without showing controls, while
Up/Down now only show controls instead of toggling playback.
2026-04-07 18:51:25 +02:00
8a6c745332 fix: reduce memory cache size percentage in TvActivity 2026-04-07 18:51:07 +02:00
69ae10c761 Stabilize HLS playback timestamps 2026-04-07 17:18:42 +02:00
985fdec3a5 feat: add play/pause toggle on key press in TV player screen 2026-04-07 16:58:18 +02:00
4b0e02e017 Fix TV audio sink capability handling 2026-04-07 16:52:44 +02:00
356816ba0e feat: add HLS support by including media3-exoplayer-hls dependency 2026-04-07 16:25:54 +02:00
cca75d8a5a feat: add PendingSeekTracker to manage seek requests and improve playback accuracy 2026-04-07 15:35:14 +02:00
59c9e31ca5 feat: implement playback source planning and enhance device profile transcoding support 2026-04-07 15:18:39 +02:00
b91a8a81e8 Fix TV detail playback single-press behavior 2026-04-06 20:38:05 +02:00
9e8772467c Improve player error reporting 2026-04-06 20:20:44 +02:00
60291dc78e Keep TV detail hero visible on initial focus 2026-04-06 19:54:01 +02:00
0870e8aee4 Remove status and progress overlays from TV hero section
Removes eyebrow text, watched status chips, and progress bars from the
focused hero component on the TV home screen. The underlying model and
UI are simplified to display only the title and basic metadata.
2026-04-06 19:38:27 +02:00
f0bb37d8ec refactor: adjust image layout in TvMediaDetailScaffold for improved responsiveness 2026-04-06 19:28:55 +02:00
34ae15d645 Move TV detail hero content into body 2026-04-06 19:12:52 +02:00
9083a08f95 Refactor TV media detail backdrop layout 2026-04-06 19:00:24 +02:00
32d50f750f Use BringIntoViewSpec for TV home focus alignment 2026-04-06 18:38:20 +02:00
527 changed files with 108973 additions and 15186 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@
local.properties
.codex/
openspec
.opencode
*.patch

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# Purefin AGENTS.md
Follow these instructions before making changes:
* Understand the relevant files before editing.
* Always follow official Android recommendations and best practices.
* Always include the pages you have read in your reply, even in planning mode.
* Prefer small, targeted changes over broad refactors.
* Preserve existing architecture and naming unless the task requires a change.
* Do not introduce new dependencies without a clear reason.
* Ask for clarification or leave a note when requirements are ambiguous.
---
## Ui principles
* Ui design should align with the material ui 3 design system
* For new components check https://m3.material.io/components if there is an available solution for the problem.
* Using Box components are a red flag and should be only used when there is no other option.
* When styling components check the material ui 3 site first and use it for the decision https://m3.material.io/styles
---
## Repository Expectations
* Always use the currently used syntax in the codebase. Include examples for any naming you introduce.
* Avoid touching unrelated files.
* Do not fix unrelated existing problems in the codebase.
* Prefer consistency with the existing codebase over idealized patterns.
* When modifying a file, match its local style and conventions.
* Favor readable, maintainable code over clever code.
---
## Testing
* Running a build is sufficient to verify your implementation.
* Do not write tests unless explicitly asked to.
---
## Git and Commit Rules
When asked to prepare a commit:
* Use **Conventional Commits** format.
* Format:
```
<type>(<scope>): <description>
```
* The description should not be too long, but must include any major change.
* Use imperative mood.
* Keep the subject concise.
**Common types:**
* `feat` new feature
* `fix` bug fix
* `refactor` code change without feature or fix
* `test` adding or updating tests
* `docs` documentation changes
* `chore` maintenance tasks
* `build` build system changes
* `ci` CI/CD changes
---
## Preferred Workflow
1. Read the task carefully.
2. Read relevant official Google/Android documentation.
3. Inspect the relevant files and nearby code.
4. Choose the simplest solution aligned with Googles standards.
5. Check if the code can be simplified:
* If yes, simplify it.
* Do not reduce readability.
6. Summarize the result, including anything not verified.

View File

@@ -53,9 +53,44 @@ cd purefin
- For Android devices: Select your device/emulator and click Run
- For Android TV: Select an Android TV emulator or device
### Download APK
### APK Repository
_Release builds coming soon_
APKs are distributed via the [APK Version Manager](https://apks.t.bbara.hu/docs) at `https://apks.t.bbara.hu`.
#### App names
| Variant | App name | Update URL |
|---------|----------|--------------------------------------------------------------|
| Release (phone) | `purefin-app` | `https://apks.t.bbara.hu/apps/purefin-app/update.json` |
| Debug (phone) | `purefin-app-debug` | `https://apks.t.bbara.hu/apps/purefin-app-debug/update.json` |
| Release (TV) | `purefin-tv` | `https://apks.t.bbara.hu/apps/purefin-tv/update.json` |
#### Building
```bash
# Phone
./gradlew :app:assembleDebug # debug APK
./gradlew :app:assembleRelease # release APK (unsigned)
# Android TV
./gradlew :app-tv:assembleDebug # debug APK
./gradlew :app-tv:assembleRelease # release APK (unsigned)
```
APK outputs go to `app/build/outputs/apk/<variant>/` and `app-tv/build/outputs/apk/<variant>/`.
#### Uploading
Upload via the API at `POST /api/apps/{app_name}/upload`:
```bash
curl -X POST "https://apks.t.bbara.hu/api/apps/purefin-app-debug/upload" \
-F "apk_file=@app/build/outputs/apk/debug/app-debug.apk" \
-F "version_code=$(grep purefinDebugVersionCode gradle.properties | cut -d= -f2)" \
-F "version_name=0.1-debug"
```
The `version_code` is defined in `gradle.properties` (`purefinReleaseVersionCode` for phone release, `purefinDebugVersionCode` for phone debug, `purefinTvVersionCode` for TV). The `version_name` is `0.1` for release or `0.1-debug` for debug. Both fields are required — the server cannot auto-extract them from the APK.
## Usage

View File

@@ -7,17 +7,19 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.google.gms.google.services)
alias(libs.plugins.google.firebase.crashlytics)
}
android {
namespace = "hu.bbara.purefin.tv"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "hu.bbara.purefin.tv"
minSdk = 29
targetSdk = 36
versionCode = 2000001
versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -51,12 +53,15 @@ kotlin {
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":core:player"))
implementation(project(":feature:shared"))
implementation(project(":data"))
implementation(project(":core"))
implementation(project(":core-model"))
implementation(project(":core-ui"))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.activity.compose)
@@ -83,8 +88,7 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.firebase.crashlytics)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "407366050869",
"project_id": "purefin-12eec",
"storage_bucket": "purefin-12eec.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:407366050869:android:8fa3540adfc514add73d7c",
"android_client_info": {
"package_name": "hu.bbara.purefin.tv"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD8a9BnlFSmcw4v0gukC1qcyESznUUWsXI"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -1,230 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvHomeContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvHomeContent_focusesFirstAvailableItemByDefault() {
composeRule.setContent {
AppTheme {
TvHomeContent(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMediaFocused = {},
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
}
@Test
fun tvHomeScreen_showsHeroForInitiallyFocusedItem() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
}
@Test
fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatchingRow(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road")
}
@Test
fun tvHomeScreen_compactHeroKeepsFirstRowVisible() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsDisplayed()
.assertIsFocused()
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
)
)
}
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
),
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road",
progress = 8.0
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> {
return listOf(
LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
isEmpty = false
)
)
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
return mapOf(
libraryId to listOf(
PosterItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "44444444-4444-4444-4444-444444444444",
title = "Arrival",
progress = null,
libraryId = libraryId
)
)
)
)
}
private fun sampleMovie(
id: String,
title: String,
progress: Double?,
libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222")
): Movie {
return Movie(
id = UUID.fromString(id),
libraryId = libraryId,
title = title,
progress = progress,
watched = false,
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
}
}

View File

@@ -0,0 +1,49 @@
package hu.bbara.purefin.ui.common.media
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TvMediaDetailBringIntoViewSpecTest {
@Test
fun returnsZero_whenFocusedItemIsAlreadyFullyVisible() {
assertEquals(
0f,
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
offset = 320f,
size = 52f,
containerSize = 1080f
),
0.0001f
)
}
@Test
fun scrollsOnlyEnoughToRevealFocusedItemBelowViewport() {
assertEquals(
36f,
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
offset = 1064f,
size = 52f,
containerSize = 1080f
),
0.0001f
)
}
@Test
fun scrollsBackJustToTopEdge_whenFocusedItemMovesAboveViewport() {
assertEquals(
-24f,
TvMediaDetailBringIntoViewSpec.calculateScrollDistance(
offset = -24f,
size = 52f,
containerSize = 1080f
),
0.0001f
)
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.episode
package hu.bbara.purefin.ui.screen.episode
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
@@ -6,16 +6,18 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@@ -27,13 +29,12 @@ class EpisodeScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() {
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
composeRule.setContent {
AppTheme {
EpisodeScreenContent(
TvEpisodeScreenContent(
episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance",
onBack = {},
onPlay = {}
)
}
@@ -48,12 +49,6 @@ class EpisodeScreenContentTest {
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithText("Series").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
}
@@ -61,10 +56,9 @@ class EpisodeScreenContentTest {
fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
composeRule.setContent {
AppTheme {
EpisodeScreenContent(
TvEpisodeScreenContent(
episode = sampleEpisode(progress = null),
seriesTitle = "Severance",
onBack = {},
onPlay = {}
)
}
@@ -78,6 +72,32 @@ class EpisodeScreenContentTest {
composeRule.onAllNodesWithText("Series").assertCountEquals(0)
}
@Test
fun episodeScreenContent_startsPlaybackOnFirstCenterPress() {
var playCount = 0
composeRule.setContent {
AppTheme {
TvEpisodeScreenContent(
episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance",
onPlay = { playCount++ }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
assertEquals(1, playCount)
}
private fun sampleEpisode(progress: Double?): Episode {
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
@@ -85,7 +105,9 @@ class EpisodeScreenContentTest {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 2,
index = 4,
title = "The You You Are",
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",

View File

@@ -0,0 +1,501 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie
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.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvHomeContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvHomeContent_focusesFirstAvailableItemByDefault() {
composeRule.setContent {
AppTheme {
TvHomeContent(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMediaFocused = {},
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
}
@Test
fun tvHomeScreen_showsHeroForInitiallyFocusedItem() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
}
@Test
fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatchingRow(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road")
}
@Test
fun tvHomeScreen_compactHeroKeepsFirstRowVisible() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsDisplayed()
.assertIsFocused()
}
@Test
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
}
@Test
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUpRow(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
}
@Test
fun tvHomeScreen_usesReducedMediaCardWidths() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
val expectedLandscapeWidth = with(composeRule.density) { 223.2.dp.toPx() }
assertEquals(expectedLandscapeWidth, nodeWidth(TvHomeInitialFocusTag), 2f)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
val expectedPosterWidth = with(composeRule.density) { 122.4.dp.toPx() }
composeRule.onNodeWithTag(tvHomeLibraryFirstItemTag(sampleLibraryId())).assertIsDisplayed()
assertEquals(
expectedPosterWidth,
nodeWidth(tvHomeLibraryFirstItemTag(sampleLibraryId())),
2f
)
}
private fun sampleContinueWatching(): List<MediaUiModel> {
return listOf(
MovieUiModel(
sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
)
)
}
private fun sampleContinueWatchingRow(): List<MediaUiModel> {
return listOf(
MovieUiModel(
sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
),
MovieUiModel(
sampleMovie(
id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road",
progress = 8.0
)
)
)
}
private fun sampleNextUp(): List<MediaUiModel> {
return listOf(
EpisodeUiModel(
sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
)
)
}
private fun sampleNextUpRow(): List<MediaUiModel> {
return listOf(
EpisodeUiModel(
sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
),
EpisodeUiModel(
sampleEpisode(
id = "77777777-7777-7777-7777-777777777777",
title = "Safe",
releaseDate = "2024-02-09"
)
)
)
}
private fun sampleLibraries(): List<LibraryUiModel> {
return listOf(
LibraryUiModel(
id = sampleLibraryId(),
name = "Movies",
type = LibraryKind.MOVIES,
posterUrl = "",
size = 1,
isEmpty = false
)
)
}
private fun sampleLibraryContent(): Map<UUID, List<MediaUiModel>> {
val libraryId = sampleLibraryId()
return mapOf(
libraryId to listOf(
MovieUiModel(
sampleMovie(
id = "44444444-4444-4444-4444-444444444444",
title = "Arrival",
progress = null,
libraryId = libraryId
)
)
)
)
}
private fun sampleEpisode(
id: String,
title: String,
releaseDate: String = "2002-09-20",
progress: Double? = 18.0,
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
): Episode {
return Episode(
id = UUID.fromString(id),
seriesId = seriesId,
seriesName = "Firefly",
seasonId = seasonId,
seasonIndex = 1,
index = 1,
title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
releaseDate = releaseDate,
rating = "16+",
runtime = "44m",
progress = progress,
watched = false,
format = "HD",
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
cast = emptyList()
)
}
private fun sampleMovie(
id: String,
title: String,
progress: Double?,
libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222")
): Movie {
return Movie(
id = UUID.fromString(id),
libraryId = libraryId,
title = title,
progress = progress,
watched = false,
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
}
private fun sampleLibraryId(): UUID {
return UUID.fromString("33333333-3333-3333-3333-333333333333")
}
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
val expectedRowTop = viewportTop() + with(composeRule.density) {
TvHomeFocusedItemTopOffset.toPx()
}
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
}
private fun viewportTop(): Float {
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun rowTop(sectionRowTag: String): Float {
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun nodeWidth(tag: String): Float {
return composeRule.onNodeWithTag(tag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.width
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui
package hu.bbara.purefin.ui.screen.home.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
@@ -24,9 +24,12 @@ 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.navigation.LibraryDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvNavigationDrawerTest {
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
@Test
fun tvNavigationDrawer_updatesSelectedDestination() {
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME)
val libraryRoute = Route.LibraryRoute(
library = LibraryDto(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Movies"
)
)
var selectedDestination by mutableStateOf<Route>(Route.Home)
composeRule.setContent {
AppTheme {
TvNavigationDrawer(
destinations = listOf(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
destination = Route.Home,
label = "Home",
icon = Icons.Outlined.Home
),
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
destination = libraryRoute,
label = "Movies",
icon = Icons.Outlined.Collections
)
),

View File

@@ -1,12 +1,12 @@
package hu.bbara.purefin.tv.library.ui
package hu.bbara.purefin.ui.screen.libraries
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -20,22 +20,24 @@ class TvLibrariesOverviewScreenTest {
@Test
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
val libraries = listOf(
LibraryItem(
LibraryUiModel(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
name = "Movies",
type = CollectionType.MOVIES,
type = LibraryKind.MOVIES,
posterUrl = "",
size = 1,
isEmpty = false
),
LibraryItem(
LibraryUiModel(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Shows",
type = CollectionType.TVSHOWS,
type = LibraryKind.SERIES,
posterUrl = "",
size = 1,
isEmpty = false
)
)
var openedLibrary: LibraryItem? = null
var openedLibrary: LibraryUiModel? = null
composeRule.setContent {
AppTheme {

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.movie
package hu.bbara.purefin.ui.screen.movie
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
@@ -6,16 +6,18 @@ import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@@ -27,12 +29,11 @@ class MovieScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun movieScreenContent_focusesBack_thenMovesToPlayButton() {
fun movieScreenContent_focusesPlayButton() {
composeRule.setContent {
AppTheme {
MovieScreenContent(
TvMovieScreenContent(
movie = sampleMovie(progress = 42.0),
onBack = {},
onPlay = {}
)
}
@@ -43,14 +44,34 @@ class MovieScreenContentTest {
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
composeRule.onNodeWithTag(MoviePlayButtonTag)
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
@Test
fun movieScreenContent_startsPlaybackOnFirstCenterPress() {
var playCount = 0
composeRule.setContent {
AppTheme {
TvMovieScreenContent(
movie = sampleMovie(progress = 42.0),
onPlay = { playCount++ }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
assertEquals(1, playCount)
}
private fun sampleMovie(progress: Double?): Movie {

View File

@@ -1,21 +1,24 @@
package hu.bbara.purefin.app.content.series
package hu.bbara.purefin.ui.screen.series
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
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.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
import hu.bbara.purefin.ui.screen.series.components.SeriesNextUpEpisodeCardTag
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@@ -27,12 +30,11 @@ class SeriesScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() {
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
composeRule.setContent {
AppTheme {
SeriesScreenContent(
TvSeriesScreenContent(
series = sampleSeriesWithEpisodes(),
onBack = {},
onPlayEpisode = {}
)
}
@@ -42,27 +44,19 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Severance").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
.assertIsFocused()
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
}
@Test
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() {
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
composeRule.setContent {
AppTheme {
SeriesScreenContent(
TvSeriesScreenContent(
series = sampleSeriesWithoutEpisodes(),
onBack = {},
onPlayEpisode = {}
)
}
@@ -71,15 +65,34 @@ class SeriesScreenContentTest {
composeRule.waitForIdle()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed()
composeRule.onNodeWithContentDescription("Back")
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
.assertIsDisplayed()
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionDown)
}
@Test
fun seriesScreenContent_startsPlaybackOnFirstCenterPress() {
var playCount = 0
composeRule.setContent {
AppTheme {
TvSeriesScreenContent(
series = sampleSeriesWithEpisodes(),
onPlayEpisode = { playCount++ }
)
}
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
assertEquals(1, playCount)
}
private fun sampleSeriesWithEpisodes(): Series {
@@ -107,7 +120,9 @@ class SeriesScreenContentTest {
Episode(
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 1,
index = 1,
title = "Good News About Hell",
synopsis = "Mark is promoted after an unexpected tragedy.",
@@ -123,7 +138,9 @@ class SeriesScreenContentTest {
Episode(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 1,
index = 2,
title = "Half Loop",
synopsis = "Mark takes the team out for a sanctioned dinner.",

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.touchscreen"
@@ -14,7 +16,7 @@
android:required="true" />
<application
android:name=".TvApplication"
android:name="hu.bbara.purefin.tv.TvApplication"
android:allowBackup="true"
android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -23,9 +25,13 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/purefin_logo_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin">
<meta-data
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
<activity
android:name=".TvActivity"
android:name="hu.bbara.purefin.tv.TvActivity"
android:exported="true"
android:theme="@style/Theme.Purefin">
<intent-filter>
@@ -34,6 +40,9 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,134 +0,0 @@
package hu.bbara.purefin.app.content.episode
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.EpisodeDto
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun EpisodeScreen(
episode: EpisodeDto,
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val navigationManager = LocalNavigationManager.current
val backStack = LocalNavigationBackStack.current
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) {
viewModel.selectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
}
val episode = viewModel.episode.collectAsState()
val seriesTitle = viewModel.seriesTitle.collectAsState()
val selectedEpisode = episode.value
if (selectedEpisode == null) {
PurefinWaitingScreen()
return
}
EpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = seriesTitle.value,
onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
navigationManager.navigate(
Route.PlayerRoute(mediaId = selectedEpisode.id.toString())
)
}
},
modifier = modifier
)
}
@Composable
internal fun EpisodeScreenContent(
episode: Episode,
seriesTitle: String?,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id,
modifier = modifier,
heroContent = {
EpisodeHeroSection(
episode = episode,
seriesTitle = seriesTitle,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) {
item {
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp))
MediaDetailOverviewSection(
synopsis = episode.synopsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
}
}
item {
Column(modifier = it.fillMaxWidth()) {
MediaDetailPlaybackSection(
audioTrack = "ENG",
subtitles = "ENG",
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
}
}
if (episode.cast.isNotEmpty()) {
item {
Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp))
MediaCastRow(cast = episode.cast)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}

View File

@@ -1,83 +0,0 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.model.Movie
internal const val MoviePlayButtonTag = "movie-play-button"
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun MovieHeroSection(
movie: Movie,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 760.dp)
) {
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 42.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(18.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = movie.year)
MediaMetaChip(text = movie.rating)
MediaMetaChip(text = movie.runtime)
MediaMetaChip(
text = movie.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.35f),
textColor = scheme.primary
)
}
Spacer(modifier = Modifier.height(24.dp))
MediaResumeButton(
text = movie.playButtonText(),
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
onClick = onPlay,
modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.focusRequester(playFocusRequester)
.testTag(MoviePlayButtonTag)
)
}
}
private fun Movie.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

@@ -1,109 +0,0 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun MovieScreen(
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
}
val movieItem = viewModel.movie.collectAsState()
if (movieItem.value != null) {
MovieScreenContent(
movie = movieItem.value!!,
onBack = viewModel::onBack,
onPlay = viewModel::onPlay,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}
@Composable
internal fun MovieScreenContent(
movie: Movie,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) {
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id,
modifier = modifier,
heroContent = {
MovieHeroSection(
movie = movie,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) {
item {
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp))
MediaDetailOverviewSection(
synopsis = movie.synopsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
}
}
item {
Column(modifier = it.fillMaxWidth()) {
MediaDetailPlaybackSection(
audioTrack = movie.audioTrack,
subtitles = movie.subtitles,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
}
}
if (movie.cast.isNotEmpty()) {
item {
Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp))
MediaCastRow(cast = movie.cast)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}

View File

@@ -1,385 +0,0 @@
package hu.bbara.purefin.app.content.series
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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
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.PlayCircle
import androidx.compose.material3.Icon
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.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.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.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
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.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.api.ImageType
internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SeriesMetaChips(series: Series) {
val scheme = MaterialTheme.colorScheme
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = series.year)
MediaMetaChip(text = "${series.seasonCount} Seasons")
}
}
@Composable
internal fun SeasonTabs(
seasons: List<Season>,
selectedSeason: Season?,
modifier: Modifier = Modifier,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
onSelect: (Season) -> Unit
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
seasons.forEachIndexed { index, season ->
SeasonTab(
name = season.name,
isSelected = season == selectedSeason,
onSelect = { onSelect(season) },
modifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
)
)
}
}
}
@Composable
private fun SeasonTab(
name: String,
isSelected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
var isFocused by remember { mutableStateOf(false) }
val color = if (isSelected || isFocused) scheme.primary else mutedStrong
val underlineColor = if (isSelected || isFocused) scheme.primary else Color.Transparent
val underlineHeight = if (isFocused) 3.dp else 2.dp
Column(
modifier = modifier
.padding(bottom = 8.dp)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onSelect() }
) {
Text(
text = name,
color = color,
fontSize = 13.sp,
fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.height(underlineHeight)
.width(52.dp)
.background(underlineColor)
)
}
}
@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,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(episodes) { episode ->
EpisodeCard(episode = episode)
}
}
}
@Composable
internal fun SeriesHeroSection(
series: Series,
nextUpEpisode: Episode?,
onPlayEpisode: (Episode) -> Unit,
playFocusRequester: FocusRequester,
firstContentFocusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 760.dp)
) {
Text(
text = series.name,
color = scheme.onBackground,
fontSize = 42.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(18.dp))
SeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp))
if (nextUpEpisode != null) {
Text(
text = nextUpEpisode.heroStatusText(),
color = scheme.primary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = nextUpEpisode.title,
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Episode ${nextUpEpisode.index}${nextUpEpisode.runtime}",
color = mutedStrong,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(24.dp))
MediaResumeButton(
text = nextUpEpisode.playButtonText(),
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
onClick = { onPlayEpisode(nextUpEpisode) },
modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.focusRequester(playFocusRequester)
.focusProperties { down = firstContentFocusRequester }
.testTag(SeriesPlayButtonTag)
)
} else {
Text(
text = "Choose a season below to start watching.",
color = mutedStrong,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
episode: Episode
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
Column(
modifier = Modifier
.width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged { isFocused = it.isFocused }
.clickable { viewModel.onSelectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
) },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surface)
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant,
shape = RoundedCornerShape(12.dp)
)
) {
PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.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),
modifier = Modifier
.align(Alignment.BottomStart)
)
} else {
WatchStateIndicator(
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
}
Column(
) {
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Episode ${episode.index}",
color = mutedStrong,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
MediaCastRow(
cast = cast,
modifier = modifier,
cardWidth = 84.dp,
nameSize = 11.sp,
roleSize = 10.sp
)
}
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}
private fun Episode.heroStatusText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
}

View File

@@ -1,141 +0,0 @@
package hu.bbara.purefin.app.content.series
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun SeriesScreen(
series: SeriesDto,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
LaunchedEffect(series.id) {
viewModel.selectSeries(series.id)
}
val series = viewModel.series.collectAsState()
val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenContent(
series = seriesData,
onBack = viewModel::onBack,
onPlayEpisode = viewModel::onPlayEpisode,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}
@Composable
internal fun SeriesScreenContent(
series: Series,
onBack: () -> Unit,
onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier,
) {
fun getDefaultSeason(): Season {
for (season in series.seasons) {
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
if (firstUnwatchedEpisode != null) return season
}
return series.seasons.first()
}
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
val nextUpEpisode = remember(series.id) {
series.seasons.firstNotNullOfOrNull { season ->
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
}
val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) {
if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
}
TvMediaDetailScaffold(
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id,
modifier = modifier,
heroContent = {
SeriesHeroSection(
series = series,
nextUpEpisode = nextUpEpisode,
onPlayEpisode = { onPlayEpisode(it.id) },
playFocusRequester = playFocusRequester,
firstContentFocusRequester = firstContentFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
) {
item {
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp))
MediaDetailOverviewSection(
synopsis = series.synopsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
}
}
item {
SeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason.value,
firstItemFocusRequester = firstContentFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeason.value = it },
modifier = it
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
EpisodeCarousel(
episodes = selectedSeason.value.episodes,
modifier = it
)
}
if (series.cast.isNotEmpty()) {
item {
Column(modifier = it) {
Spacer(modifier = Modifier.height(20.dp))
MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp))
CastRow(cast = series.cast)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}

View File

@@ -1,131 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.model.CastMember
@Composable
fun MediaMetaChip(
text: String,
background: Color = MaterialTheme.colorScheme.surfaceVariant,
border: Color = Color.Transparent,
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(background)
.border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = textColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
fun MediaCastRow(
cast: List<CastMember>,
modifier: Modifier = Modifier,
cardWidth: Dp = 96.dp,
nameSize: TextUnit = 12.sp,
roleSize: TextUnit = 10.sp
) {
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) { member ->
Column(modifier = Modifier.width(cardWidth)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surfaceVariant)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(scheme.surfaceVariant.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = mutedStrong
)
}
} else {
PurefinAsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = scheme.onBackground,
fontSize = nameSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = mutedStrong,
fontSize = roleSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,68 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinComplexTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = if (leadingIcon != null) {
{ Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null,
trailingIcon = if (trailingIcon != null) {
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null,
visualTransformation = visualTransformation,
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant,
unfocusedContainerColor = scheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
))
}
}

View File

@@ -1,76 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinPasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
) {
val scheme = MaterialTheme.colorScheme
val showField = remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) },
trailingIcon =
{
IconButton(
onClick = { showField.value = !showField.value },
) {
Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant)
}
},
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant,
unfocusedContainerColor = scheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
)
)
}
}

View File

@@ -1,199 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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 PurefinWaitingScreen(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val accentColor = scheme.primary
val backgroundColor = scheme.background
val surfaceColor = scheme.surface
val textPrimary = scheme.onSurface
val textSecondary = scheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "waiting-pulse")
val pulseScale = transition.animateFloat(
initialValue = 0.9f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-scale"
)
val pulseAlpha = transition.animateFloat(
initialValue = 0.2f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-alpha"
)
val gradient = Brush.radialGradient(
colors = listOf(
accentColor.copy(alpha = 0.28f),
backgroundColor
)
)
Box(
modifier = modifier
.fillMaxSize()
.background(gradient)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(surfaceColor.copy(alpha = 0.92f))
.padding(horizontal = 28.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(86.dp)
.graphicsLayer {
scaleX = pulseScale.value
scaleY = pulseScale.value
}
.alpha(pulseAlpha.value)
.border(
width = 2.dp,
color = accentColor.copy(alpha = 0.6f),
shape = RoundedCornerShape(26.dp)
)
)
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(22.dp))
.background(accentColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Movie,
contentDescription = null,
tint = scheme.onPrimary,
modifier = Modifier.size(40.dp)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Just a moment",
color = textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "I am doing all I can...",
color = textSecondary,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(24.dp))
WaitingDots(accentColor = accentColor)
}
}
}
@Composable
private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "waiting-dots")
val firstAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-1"
)
val secondAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-2"
)
val thirdAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-3"
)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
WaitingDot(alpha = firstAlpha.value, color = accentColor)
WaitingDot(alpha = secondAlpha.value, color = accentColor)
WaitingDot(alpha = thirdAlpha.value, color = accentColor)
}
}
@Composable
private fun WaitingDot(alpha: Float, color: Color) {
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer {
val scale = 0.7f + (alpha * 0.3f)
scaleX = scale
scaleY = scale
}
.alpha(alpha)
.background(color, CircleShape)
)
}

View File

@@ -1,56 +0,0 @@
package hu.bbara.purefin.common.ui.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.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(52.dp)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
.clip(CircleShape)
.background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground
)
}
}

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.common.ui.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.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun MediaActionButton(
backgroundColor: Color,
iconColor: Color,
icon: ImageVector,
modifier: Modifier = Modifier,
height: Dp,
onClick: () -> Unit = {},
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(height)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.6f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
}
}

View File

@@ -1,247 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis
internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailCornerArtworkTopPadding = 40.dp
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
private val MediaDetailMinimumContentWidth = 280.dp
private val MediaDetailContentArtworkGap = 32.dp
@Composable
internal fun TvMediaDetailScaffold(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any,
modifier: Modifier = Modifier,
headerHeightFraction: Float = 0.48f,
heroContent: @Composable ColumnScope.() -> Unit,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
) {
val scheme = MaterialTheme.colorScheme
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
val listState = rememberLazyListState()
LaunchedEffect(resetScrollKey) {
listState.scrollToItem(0)
}
Box(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
item {
TvMediaDetailHeader(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
headerHeightFraction = headerHeightFraction,
heroContent = heroContent
)
}
bodyContent(contentPadding)
}
}
}
@Composable
private fun TvMediaDetailHeader(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
headerHeightFraction: Float,
heroContent: @Composable ColumnScope.() -> Unit
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val headerHeight = screenHeight * headerHeightFraction
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = headerHeight)
) {
val contentMaxWidth = (
maxWidth -
(MediaDetailHorizontalPadding * 2) -
artworkWidth -
MediaDetailContentArtworkGap
).coerceAtLeast(MediaDetailMinimumContentWidth)
Box(modifier = Modifier.fillMaxWidth()) {
TvMediaDetailCornerArtwork(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(
top = MediaDetailCornerArtworkTopPadding,
end = MediaDetailHorizontalPadding
)
)
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
.widthIn(max = contentMaxWidth)
) {
heroContent()
}
}
}
}
@Composable
private fun TvMediaDetailCornerArtwork(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.width(artworkWidth)
.aspectRatio(artworkAspectRatio)
.clip(MediaDetailCornerArtworkShape)
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
) {
PurefinAsyncImage(
model = artworkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.52f },
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.2f),
1.0f to scheme.background.copy(alpha = 0.56f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.06f),
0.65f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.74f)
)
)
)
)
}
}
@Composable
internal fun MediaDetailSectionTitle(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
@Composable
internal fun MediaDetailOverviewSection(
synopsis: String,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
MediaSynopsis(
synopsis = synopsis,
modifier = modifier,
title = "Overview",
titleColor = scheme.onBackground,
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
titleFontSize = 22.sp,
bodyFontSize = 16.sp,
bodyLineHeight = 24.sp,
titleSpacing = 14.dp,
collapsedLines = 5,
collapseInitially = false
)
}
@Composable
internal fun MediaDetailPlaybackSection(
audioTrack: String,
subtitles: String,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
MediaDetailSectionTitle(text = "Playback")
Spacer(modifier = Modifier.height(14.dp))
MediaPlaybackSettings(
backgroundColor = scheme.surfaceContainerHigh,
foregroundColor = scheme.onBackground,
audioTrack = audioTrack,
subtitles = subtitles
)
}
}

View File

@@ -1,73 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
@Composable
fun MediaPlayButton(
backgroundColor: Color,
foregroundColor: Color,
text: String = "Play",
subText: String? = null,
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.height(size)
.clip(CircleShape)
.background(backgroundColor)
.padding(start = 16.dp, end = 32.dp)
.clickable { onClick() },
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = foregroundColor,
modifier = Modifier.size(42.dp)
)
Column() {
Text(
text = text,
color = foregroundColor,
fontSize = TextUnit(
value = 16f,
type = TextUnitType.Sp
)
)
subText?.let {
Text(
text = subText,
color = foregroundColor.copy(alpha = 0.7f),
fontSize = TextUnit(
value = 14f,
type = TextUnitType.Sp
)
)
}
}
}
}

View File

@@ -1,103 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MediaPlaybackSettings(
backgroundColor: Color,
foregroundColor: Color,
audioTrack: String,
subtitles: String,
audioIcon: ImageVector = Icons.Outlined.VolumeUp,
subtitleIcon: ImageVector = Icons.Outlined.ClosedCaption,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
) {
MediaSettingDropdown(
backgroundColor = backgroundColor,
foregroundColor = foregroundColor,
label = "Audio Track",
value = audioTrack,
icon = audioIcon
)
Spacer(modifier = Modifier.height(12.dp))
MediaSettingDropdown(
backgroundColor = backgroundColor,
foregroundColor = foregroundColor,
label = "Subtitles",
value = subtitles,
icon = subtitleIcon
)
}
}
@Composable
private fun MediaSettingDropdown(
backgroundColor: Color,
foregroundColor: Color,
label: String,
value: String,
icon: ImageVector
) {
Row (
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = foregroundColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.width(12.dp))
Row(
modifier = Modifier
.height(38.dp)
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = foregroundColor, fontSize = 14.sp)
}
Icon(
imageVector = Icons.Outlined.ExpandMore,
contentDescription = null,
tint = foregroundColor
)
}
}
}

View File

@@ -1,38 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
/**
* Async image that falls back to theme-synced color blocks so loading/error states
* stay aligned with PurefinTheme's colorScheme.
*/
@Composable
fun PurefinAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
) {
val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant)
// Convert empty string to null to properly trigger fallback
val effectiveModel = when {
model is String && model.isEmpty() -> null
else -> model
}
AsyncImage(
model = effectiveModel,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
placeholder = placeholderPainter,
error = placeholderPainter,
fallback = placeholderPainter
)
}

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.common.ui.components
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PurefinIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Int = 52
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(size.dp)
.border(if (isFocused) 2.5.dp else 0.dp, if (isFocused) scheme.onPrimary else Color.Transparent, CircleShape)
.clip(CircleShape)
.background(if (isFocused) scheme.primary else scheme.secondary)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onSecondary
)
}
}

View File

@@ -1,87 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
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.Modifier
import kotlinx.coroutines.delay
/**
* A composable that displays content for a specified duration after the value becomes null.
*
* @param value The value to display. When set to a non-null value, it will be shown immediately.
* When set to null, the previously shown value will remain visible for [hideAfterMillis]
* before being hidden.
* @param hideAfterMillis The duration in milliseconds to keep showing the last value after [value] becomes null.
* Defaults to 1000ms (1 second).
* @param content The composable content to display, receiving the current non-null value.
*/
@Composable
fun <T> EmptyValueTimedVisibility(
value: T?,
hideAfterMillis: Long = 1_000,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
val shownValue = remember { mutableStateOf<T?>(null) }
LaunchedEffect(value) {
if (value == null) {
delay(hideAfterMillis)
shownValue.value = null
}
shownValue.value = value
}
shownValue.value?.let {
content(it)
}
}
/**
* Displays [content] whenever [value] changes and hides it after [hideAfterMillis]
* milliseconds without further updates.
*
* @param value The value whose changes should trigger visibility.
* @param hideAfterMillis Duration in milliseconds after which the content will be hidden
* if [value] has not changed again.
* @param content The composable to render while visible.
*/
@Composable
fun <T> ValueChangeTimedVisibility(
value: T,
hideAfterMillis: Long = 1_000,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
var displayedValue by remember { mutableStateOf(value) }
var isVisible by remember { mutableStateOf(false) }
var hasInitialValue by remember { mutableStateOf(false) }
LaunchedEffect(value) {
displayedValue = value
if (!hasInitialValue) {
hasInitialValue = true
return@LaunchedEffect
}
isVisible = true
delay(hideAfterMillis)
isVisible = false
}
AnimatedVisibility(
visible = isVisible,
modifier = modifier,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
content(displayedValue)
}
}

View File

@@ -1,214 +0,0 @@
package hu.bbara.purefin.login.ui
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.Spacer
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.feature.shared.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
// Observe ViewModel state
val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
} else {
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
// Logo Section
Box(
modifier = Modifier
.size(100.dp)
.background(scheme.primary, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie,
contentDescription = "Logo",
tint = scheme.onPrimary,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp))
// Form Section
Text(
text = "Connect to Server",
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = scheme.error,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(
scheme.errorContainer,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
PurefinComplexTextField(
label = "Server URL",
value = serverUrl,
onValueChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = password,
onValueChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
Spacer(modifier = Modifier.weight(0.5f))
// Footer Links
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = scheme.onSurfaceVariant)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = scheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.navigation
package hu.bbara.purefin.modules
import androidx.navigation3.runtime.EntryProviderScope
import dagger.Module
@@ -6,7 +6,15 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.tvEpisodeSection
import hu.bbara.purefin.navigation.tvHomeSection
import hu.bbara.purefin.navigation.tvLibrarySection
import hu.bbara.purefin.navigation.tvLoginSection
import hu.bbara.purefin.navigation.tvMovieSection
import hu.bbara.purefin.navigation.tvPlayerSection
import hu.bbara.purefin.navigation.tvSettingsSection
import hu.bbara.purefin.navigation.tvSeriesSection
@Module
@InstallIn(ActivityRetainedComponent::class)
@@ -53,4 +61,10 @@ object TvNavigationModule {
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvLibrarySection()
}
@IntoSet
@Provides
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvSettingsSection()
}
}

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.modules
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module
@InstallIn(SingletonComponent::class)
object TvPlaybackProfileFamilyModule {
@Provides
fun providePlaybackProfileFamily(): PlaybackProfileFamily = PlaybackProfileFamily.TV
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") }
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }

View File

@@ -0,0 +1,74 @@
package hu.bbara.purefin.navigation
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
import hu.bbara.purefin.ui.screen.TvAppScreen
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
fun EntryProviderScope<Route>.tvHomeSection() {
entry<Route.Home> {
TvAppScreen()
}
}
fun EntryProviderScope<Route>.tvLoginSection() {
entry<Route.LoginRoute> {
TvLoginScreen()
}
}
fun EntryProviderScope<Route>.tvMovieSection() {
entry<Route.MovieRoute> { route ->
TvMovieScreen(movie = route.item)
}
}
fun EntryProviderScope<Route>.tvSeriesSection() {
entry<Route.SeriesRoute> { route ->
TvSeriesScreen(series = route.item)
}
}
fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route ->
TvSeriesScreen(
series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
focusedSeasonId = route.item.seasonId,
focusedEpisodeId = route.item.id
)
}
}
fun EntryProviderScope<Route>.tvPlayerSection() {
entry<Route.PlayerRoute> { route ->
val navigationManager = LocalNavigationManager.current
TvPlayerScreen(
mediaId = route.mediaId,
onBack = { navigationManager.pop() }
)
}
}
fun EntryProviderScope<Route>.tvLibrarySection() {
entry<Route.LibraryRoute> { route ->
val viewModel: AppViewModel = hiltViewModel()
TvLibraryScreen(
library = route.library,
onMediaSelected = viewModel::onMediaSelected
)
}
}
fun EntryProviderScope<Route>.tvSettingsSection() {
entry<Route.SettingsRoute> {
TvSettingsScreen()
}
}

View File

@@ -2,6 +2,8 @@ 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
@@ -11,15 +13,20 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope
@@ -36,16 +43,19 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.NavigationCommand
import hu.bbara.purefin.core.data.navigation.NavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
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
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark
import kotlinx.coroutines.launch
@@ -66,7 +76,7 @@ class TvActivity : ComponentActivity() {
lateinit var navigationManager: NavigationManager
@Inject
lateinit var jellyfinApiClient: JellyfinApiClient
lateinit var sessionBootstrapper: SessionBootstrapper
@Inject
lateinit var authInterceptor: JellyfinAuthInterceptor
@@ -94,7 +104,7 @@ class TvActivity : ComponentActivity() {
}
private suspend fun init() {
jellyfinApiClient.updateApiClient()
sessionBootstrapper.initialize()
}
private fun configureImageLoader() {
@@ -109,7 +119,7 @@ class TvActivity : ComponentActivity() {
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.20)
.maxSizePercent(context, 0.08)
.build()
}
.diskCache {
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
fun MainApp(
userSessionRepository: UserSessionRepository,
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
navigationManager: NavigationManager
navigationManager: NavigationManager,
updateViewModel: AppUpdateViewModel = hiltViewModel()
) {
var sessionLoaded by remember { mutableStateOf(false) }
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = 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
LaunchedEffect(Unit) {
userSessionRepository.isLoggedIn.collect {
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
}
}
if (!sessionLoaded) {
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
return
LaunchedEffect(updateViewModel) {
updateViewModel.checkForUpdatesOnAppOpen()
}
if (isLoggedIn) {
LaunchedEffect(updateViewModel, context) {
updateViewModel.snackbarMessages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
if (!sessionLoaded) {
BackHandler {
showExitDialog = true
}
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
} else if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val appEntryProvider = entryProvider {
@@ -161,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()
@@ -171,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(
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
)
}
} else {
LoginScreen()
BackHandler {
showExitDialog = true
}
TvLoginScreen()
}
availableUpdate?.let { update ->
TvUpdateAvailableDialog(
update = update,
onAccept = updateViewModel::acceptUpdate,
onDecline = updateViewModel::declineUpdate
)
}
if (showExitDialog) {
ExitAppDialog(
onCloseConfirmed = { finishAndRemoveTask() },
onDismiss = { showExitDialog = false }
)
}
}
@Composable
private fun TvUpdateAvailableDialog(
update: AppUpdateInfo,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
val updateText = if (releaseNotes == null) {
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
} else {
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
}
AlertDialog(
onDismissRequest = onDecline,
title = { Text("Update available") },
text = { Text(updateText) },
confirmButton = {
TextButton(onClick = onAccept) {
Text("Install")
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text("Not now")
}
}
)
}
}

View File

@@ -1,98 +0,0 @@
package hu.bbara.purefin.tv
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.feature.shared.home.AppViewModel
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val libraries by viewModel.libraries.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) }
val destinations = remember(libraries, selectedDestination) {
listOf(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
label = "Home",
icon = Icons.Outlined.Home,
selected = selectedDestination == TvDrawerDestination.HOME
),
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
icon = when {
libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
selected = selectedDestination == TvDrawerDestination.LIBRARIES
)
)
}
LifecycleResumeEffect(Unit) {
viewModel.onResumed()
onPauseOrDispose { }
}
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
selectedDestination = destination
},
modifier = modifier.fillMaxSize()
) {
when (selectedDestination) {
TvDrawerDestination.HOME -> {
TvHomeScreen(
libraries = libraries,
libraryContent = latestLibraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
serverUrl = serverUrl,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
modifier = Modifier.fillMaxSize()
)
}
TvDrawerDestination.LIBRARIES -> {
TvLibrariesOverviewScreen(
libraries = libraries,
onLibrarySelected = { library ->
viewModel.onLibrarySelected(library.id, library.name)
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}

View File

@@ -2,6 +2,12 @@ package hu.bbara.purefin.tv
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import hu.bbara.purefin.core.logging.PurefinLogger
@HiltAndroidApp
class TvApplication : Application()
class TvApplication : Application() {
override fun onCreate() {
super.onCreate()
PurefinLogger.initialize(this)
}
}

View File

@@ -1,79 +0,0 @@
package hu.bbara.purefin.tv.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero
import hu.bbara.purefin.tv.home.ui.TvHomeContent
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState
import org.jellyfin.sdk.model.UUID
private const val TvHomeHeroHeightFraction = 0.32f
private val TvHomeMinHeroHeight = 160.dp
private val TvHomeMaxHeroHeight = 200.dp
@Composable
fun TvHomeScreen(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
serverUrl: String,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val heroState = rememberTvHomeHeroState(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
val heroHeight = heroState.focusedHero?.let {
(maxHeight * TvHomeHeroHeightFraction)
.coerceIn(TvHomeMinHeroHeight, TvHomeMaxHeroHeight)
}
Column(
modifier = Modifier.fillMaxSize()
) {
heroState.focusedHero?.let { hero ->
TvFocusedItemHero(
item = hero,
height = heroHeight ?: TvHomeMinHeroHeight
)
}
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = heroState.onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
}
}
}

View File

@@ -1,140 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.roundToInt
internal data class TvFocusedHeroModel(
val id: UUID,
val backdropImageUrl: String,
val eyebrowText: String,
val title: String,
val metadata: List<String>,
val watchedText: String?,
val progressFraction: Float?,
val progressLabel: String?,
) {
val metadataText: String?
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString("")
}
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
return when (this) {
is ContinueWatchingItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
else -> unsupportedType(type)
}
is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up")
is PosterItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie")
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode")
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series")
else -> unsupportedType(type)
}
}
}
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
return backdropImageUrl.ifBlank { fallbackImageUrl }
}
private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(),
watchedText = "Watched".takeIf { watched },
progressFraction = progressFraction.takeIf { !watched && it != null },
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
)
}
private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = title,
metadata = listOf(
"Episode $index",
releaseDate,
runtime,
rating,
format
).compactMetadata(),
watchedText = "Watched".takeIf { watched },
progressFraction = progressFraction.takeIf { !watched && it != null },
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
)
}
private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val unwatchedText = if (unwatchedEpisodeCount > 0) {
"$unwatchedEpisodeCount unwatched"
} else {
null
}
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
watchedText = null,
progressFraction = null,
progressLabel = null,
)
}
private fun progressFraction(progress: Double?): Float? {
val fraction = progress
?.div(100.0)
?.toFloat()
?.coerceIn(0f, 1f)
return fraction?.takeIf { it > 0f }
}
private fun progressLabel(progressFraction: Float?): String? {
return progressFraction?.let { "${(it * 100).roundToInt()}%" }
}
private fun List<String?>.compactMetadata(): List<String> {
return map(String?::orEmpty)
.map(String::trim)
.filter(String::isNotBlank)
}
private fun seasonLabel(seasonCount: Int): String {
return if (seasonCount == 1) "1 season" else "$seasonCount seasons"
}
private fun unsupportedType(type: BaseItemKind): Nothing {
throw UnsupportedOperationException("Unsupported item type: $type")
}

View File

@@ -1,189 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.layout.widthIn
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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.TextAlign
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.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroStatusTag = "tv-home-hero-status"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180
@Composable
internal fun TvFocusedItemHero(
item: TvFocusedHeroModel,
height: Dp,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.background(scheme.background)
) {
Crossfade(
targetState = item.backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
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)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f),
0.56f to scheme.background.copy(alpha = 0.1f),
1.0f to scheme.background
)
)
)
)
Crossfade(
targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content"
) { hero ->
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 18.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.eyebrowText.uppercase(),
color = scheme.primary,
fontSize = 10.sp,
letterSpacing = 1.2.sp,
fontWeight = FontWeight.Bold
)
Text(
text = hero.title,
color = scheme.onBackground,
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
hero.metadataText?.let { metadataText ->
Text(
text = metadataText,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (hero.watchedText != null || hero.progressFraction != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.clip(RoundedCornerShape(22.dp))
.background(scheme.surfaceContainerHigh.copy(alpha = 0.92f))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(22.dp))
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
hero.watchedText?.let { watchedText ->
TvHomeMetaChip(
text = watchedText,
highlighted = true,
modifier = Modifier.testTag(TvHomeHeroStatusTag)
)
}
if (hero.progressFraction != null && hero.progressLabel != null) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(188.dp)
) {
Text(
text = "Progress",
color = scheme.onSurfaceVariant.copy(alpha = 0.85f),
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
MediaProgressBar(
progress = hero.progressFraction,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
modifier = Modifier
)
}
Text(
text = hero.progressLabel,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.End,
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
)
}
}
}
}
}
}
}
}

View File

@@ -1,149 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
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.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@Composable
fun TvHomeContent(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
onMediaFocused: (FocusableItem) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
val visibleLibraries = itemRegistry.visibleLibraries
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
val libraryRefs = remember(visibleLibraries) {
visibleLibraries.associate { it.id to FocusRequester() }
}
val initialFocusRequester = remember { FocusRequester() }
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) {
withFrameNanos { }
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(scheme.background),
contentPadding = contentPadding
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
modifier = Modifier.focusRequester(continueWatchingRef)
.focusProperties {
down = nextUpRef
}
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
}
item {
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
modifier = Modifier.focusRequester(nextUpRef)
.focusProperties {
up = continueWatchingRef
libraryRefs.values.firstOrNull()?.let { down = it }
}
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
}
items(
items = visibleLibraries,
key = { it.id }
) { item ->
val ref = libraryRefs[item.id]
TvLibraryPosterSection(
title = item.name,
items = libraryContent[item.id] ?: emptyList(),
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
.focusProperties {
up = nextUpRef
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
?.let { down = it }
}
)
Spacer(modifier = Modifier.height(20.dp))
}
}
}

View File

@@ -1,111 +0,0 @@
package hu.bbara.purefin.tv.home.ui
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 hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
internal data class TvHomeItemRegistry(
val visibleLibraries: List<LibraryItem>,
private val libraryContent: Map<UUID, List<PosterItem>>,
private val continueWatching: List<ContinueWatchingItem>,
private val nextUp: List<NextUpItem>,
val firstAvailableItemId: UUID?,
) {
val firstAvailableItem: FocusableItem?
get() = firstAvailableItemId?.let(::itemById)
fun itemById(id: UUID): FocusableItem? {
return continueWatching.firstOrNull { it.id == id }
?: nextUp.firstOrNull { it.id == id }
?: visibleLibraries.asSequence()
.mapNotNull { library -> libraryContent[library.id] }
.flatten()
.firstOrNull { it.id == id }
}
}
internal class TvHomeHeroState(
val focusedHero: TvFocusedHeroModel?,
val onMediaFocused: (FocusableItem) -> Unit,
)
@Composable
internal fun rememberTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeItemRegistry {
return remember(libraries, libraryContent, continueWatching, nextUp) {
createTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
}
}
internal fun createTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeItemRegistry {
val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true }
val firstAvailableItemId = continueWatching.firstOrNull()?.id
?: nextUp.firstOrNull()?.id
?: visibleLibraries.firstOrNull()?.id?.let { libraryId ->
libraryContent[libraryId]?.firstOrNull()?.id
}
return TvHomeItemRegistry(
visibleLibraries = visibleLibraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
firstAvailableItemId = firstAvailableItemId
)
}
@Composable
internal fun rememberTvHomeHeroState(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeHeroState {
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
var focusedItemId by remember { mutableStateOf<UUID?>(null) }
val focusedItem = remember(focusedItemId, itemRegistry) {
focusedItemId?.let(itemRegistry::itemById)
}
val focusedHero = remember(focusedItem, itemRegistry) {
(focusedItem ?: itemRegistry.firstAvailableItem)?.toTvFocusedHeroModel()
}
val onMediaFocused: (FocusableItem) -> Unit = remember {
{ item ->
focusedItemId = item.id
}
}
return remember(focusedHero, onMediaFocused) {
TvHomeHeroState(
focusedHero = focusedHero,
onMediaFocused = onMediaFocused
)
}
}

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.ui.graphics.vector.ImageVector
enum class TvDrawerDestination {
HOME,
LIBRARIES
}
data class TvDrawerDestinationItem(
val destination: TvDrawerDestination,
val label: String,
val icon: ImageVector,
val selected: Boolean = false
)

View File

@@ -1,354 +0,0 @@
package hu.bbara.purefin.tv.home.ui
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
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.sp
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.dp
@Composable
fun TvContinueWatchingSection(
items: List<ContinueWatchingItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = when (item.type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
else -> null
},
progress = progressFraction,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
)
}
}
}
@Composable
fun TvNextUpSection(
items: List<NextUpItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Next Up",
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
val episode = item.episode
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
)
}
}
}
@Composable
fun TvLibraryPosterSection(
title: String,
items: List<PosterItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
TvSectionHeader(
title = title,
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard(
item = item,
posterWidth = TvHomePosterCardWidth,
showSecondaryText = true,
indicatorSize = 24,
indicatorPadding = 6.dp,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = onFocusedItem,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
@Composable
fun TvSectionHeader(
title: String,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TvHomeSectionsHorizontalPadding, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun TvHomeLandscapeCard(
title: String,
supporting: String,
imageUrl: String?,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
progress: Float? = null,
onFocusedItem: () -> Unit = {},
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale")
val cardWidth = TvHomeLandscapeCardWidth
Column(
modifier = modifier
.width(cardWidth)
.wrapContentHeight()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(TvHomeSectionsThumbShape)
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
shape = TvHomeSectionsThumbShape
)
.background(scheme.surfaceContainer)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = imageModifier
.fillMaxSize()
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scheme.background.copy(alpha = 0.04f),
scheme.background.copy(alpha = 0.12f),
scheme.background.copy(alpha = 0.56f)
)
)
)
)
if (progress != null && progress > 0f) {
MediaProgressBar(
progress = progress,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 8.dp, vertical = 8.dp)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp)
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (supporting.isNotBlank()) {
Text(
text = supporting,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,58 +0,0 @@
package hu.bbara.purefin.tv.navigation
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.episode.EpisodeScreen
import hu.bbara.purefin.app.content.movie.MovieScreen
import hu.bbara.purefin.app.content.series.SeriesScreen
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.TvAppScreen
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen
import hu.bbara.purefin.tv.player.TvPlayerScreen
fun EntryProviderScope<Route>.tvHomeSection() {
entry<Route.Home> {
TvAppScreen()
}
}
fun EntryProviderScope<Route>.tvLoginSection() {
entry<Route.LoginRoute> {
LoginScreen()
}
}
fun EntryProviderScope<Route>.tvMovieSection() {
entry<Route.MovieRoute> { route ->
MovieScreen(movie = route.item)
}
}
fun EntryProviderScope<Route>.tvSeriesSection() {
entry<Route.SeriesRoute> { route ->
SeriesScreen(series = route.item)
}
}
fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route ->
EpisodeScreen(episode = route.item)
}
}
fun EntryProviderScope<Route>.tvPlayerSection() {
entry<Route.PlayerRoute> { route ->
val navigationManager = LocalNavigationManager.current
TvPlayerScreen(
mediaId = route.mediaId,
onBack = { navigationManager.pop() }
)
}
}
fun EntryProviderScope<Route>.tvLibrarySection() {
entry<Route.LibraryRoute> { route ->
TvLibraryScreen(library = route.library)
}
}

View File

@@ -1,920 +0,0 @@
package hu.bbara.purefin.tv.player
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
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.fillMaxHeight
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.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
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.ArrowBack
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow
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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.player.model.MarkerType
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
@OptIn(UnstableApi::class)
@Composable
fun TvPlayerScreen(
mediaId: String,
viewModel: PlayerViewModel = hiltViewModel(),
onBack: () -> Unit
) {
LaunchedEffect(mediaId) {
viewModel.loadMedia(mediaId)
}
val uiState by viewModel.uiState.collectAsState()
val controlsVisible by viewModel.controlsVisible.collectAsState()
var showQueuePanel by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TrackPanelType?>(null) }
val context = LocalContext.current
LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity
if (uiState.isPlaying) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
DisposableEffect(Unit) {
onDispose {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
BackHandler(enabled = controlsVisible) {
viewModel.hideControls()
}
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) viewModel.hideControls()
}
val hiddenControlFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() }
LaunchedEffect(controlsVisible) {
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
hiddenControlFocusRequester.requestFocus()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(hiddenControlFocusRequester)
.onPreviewKeyEvent { event ->
if (!controlsVisible && event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
viewModel.seekBy(-10_000)
viewModel.showControls()
true
}
Key.DirectionRight -> {
viewModel.seekBy(10_000)
viewModel.showControls()
true
}
Key.DirectionUp, Key.DirectionDown,
Key.DirectionCenter, Key.Enter -> {
viewModel.showControls()
true
}
else -> false
}
} else {
false
}
}
.focusable()
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
}
},
update = { it.player = viewModel.player },
modifier = Modifier
.fillMaxHeight()
.align(Alignment.Center)
)
AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null,
enter = fadeIn(),
exit = fadeOut()
) {
TvPlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
focusRequester = controlsFocusRequester,
onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { viewModel.seekTo(it) },
onSeekRelative = { viewModel.seekBy(it) },
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TrackPanelType.QUALITY },
onOpenQueue = { showQueuePanel = true }
)
}
TvPlayerStateCard(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = { viewModel.next() },
onReplay = { viewModel.seekTo(0L); viewModel.togglePlayPause() },
onDismissError = { viewModel.clearError() }
)
AnimatedVisibility(
visible = trackPanelType != null,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { track ->
viewModel.selectTrack(track)
trackPanelType = null
},
onClose = { trackPanelType = null },
modifier = Modifier.fillMaxSize()
)
}
}
AnimatedVisibility(
visible = showQueuePanel,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
TvQueuePanel(
uiState = uiState,
onSelect = { id ->
viewModel.playQueueItem(id)
showQueuePanel = false
},
onClose = { showQueuePanel = false },
modifier = Modifier.fillMaxSize()
)
}
}
}
private enum class TrackPanelType { AUDIO, SUBTITLES, QUALITY }
@Composable
private fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onBack: () -> Unit,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.5f),
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
) {
TvPlayerTopBar(
title = uiState.title ?: "Playing",
subtitle = uiState.subtitle,
onBack = onBack,
onOpenQueue = onOpenQueue,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
TvPlayerBottomSection(
uiState = uiState,
focusRequester = focusRequester,
onPlayPause = onPlayPause,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge,
onNext = onNext,
onPrevious = onPrevious,
onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
}
@Composable
private fun TvPlayerTopBar(
title: String,
subtitle: String?,
onBack: () -> Unit,
onOpenQueue: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
TvIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Column {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (subtitle != null) {
Text(
text = subtitle,
color = scheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
TvIconButton(
icon = Icons.Outlined.PlaylistPlay,
contentDescription = "Queue",
onClick = onOpenQueue
)
}
}
@Composable
private fun TvPlayerBottomSection(
uiState: PlayerUiState,
focusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (uiState.isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold)
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier.clickable { onSeekLiveEdge() }
)
}
} else {
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
TvPlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause,
focusRequester = focusRequester
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
) {
Row(
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous",
onClick = onPrevious,
size = 64
)
TvIconButton(
icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) },
size = 64
)
TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
onClick = onPlayPause,
size = 72
)
TvIconButton(
icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) },
size = 64
)
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Next",
onClick = onNext,
size = 64
)
}
Row(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.HighQuality,
contentDescription = "Quality",
onClick = onOpenQualityPanel
)
TvIconButton(
icon = Icons.Outlined.Language,
contentDescription = "Audio",
onClick = onOpenAudioPanel
)
TvIconButton(
icon = Icons.Outlined.ClosedCaption,
contentDescription = "Subtitles",
onClick = onOpenSubtitlesPanel
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun TvPlayerSeekBar(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
togglePlayState: () -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
val position = positionMs.coerceIn(0, safeDuration)
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
adMarkers.map { it.copy(type = MarkerType.AD) }
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(10_000)
true
}
Key.Enter -> {
togglePlayState()
true
}
else -> false
}
} else {
false
}
}
.focusable(),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val trackHeight = if (isFocused) 6f else 4f
val trackTop = size.height / 2 - trackHeight / 2
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
size = Size(width = size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferRatio * size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
drawCircle(
color = if (isFocused) scheme.primary else scheme.primary,
radius = thumbRadius,
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = thumbRadius + 4.dp.toPx(),
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
}
combinedMarkers.forEach { marker ->
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
drawRect(
color = color,
topLeft = Offset(x - 1f, size.height / 2 - 6f),
size = Size(width = 2f, height = 12f)
)
}
}
}
}
@Composable
private fun TvPlayerStateCard(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) {
AnimatedVisibility(visible = uiState.isBuffering && uiState.error == null) {
CircularProgressIndicator(color = scheme.primary)
}
AnimatedVisibility(visible = uiState.error != null) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) { Text("Retry") }
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(containerColor = scheme.surface)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }.takeIf { it >= 0 }?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onNext) { Text("Play next") }
} else {
Text(
text = "Playback finished",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onReplay) { Text("Replay") }
}
}
}
}
}
@Composable
private fun TvTrackSelectionPanel(
panelType: TrackPanelType,
uiState: PlayerUiState,
onSelect: (TrackOption) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val (title, options, selectedId) = when (panelType) {
TrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
TrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
TrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
options.forEach { option ->
val selected = option.id == selectedId
var isTrackFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.then(if (isTrackFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(
if (isTrackFocused) scheme.primary.copy(alpha = 0.3f)
else if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
)
.onFocusChanged { isTrackFocused = it.isFocused }
.clickable { onSelect(option) }
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Text(
text = option.label,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
}
}
}
}
}
}
}
@Composable
private fun TvQueuePanel(
uiState: PlayerUiState,
onSelect: (String) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Up next",
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TvIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Close",
onClick = onClose
)
}
if (uiState.queue.isEmpty()) {
Text(
text = "No items in queue",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.queue) { item ->
var isQueueFocused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (isQueueFocused) Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(
if (isQueueFocused) scheme.primary.copy(alpha = 0.35f)
else if (item.isCurrent) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.8f)
)
.onFocusChanged { isQueueFocused = it.isFocused }
.clickable { onSelect(item.id) }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.width(72.dp)
.clip(RoundedCornerShape(10.dp))
.background(scheme.surfaceVariant)
) {
if (item.artworkUrl != null) {
PurefinAsyncImage(
model = item.artworkUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (item.isCurrent) FontWeight.Bold else FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
color = scheme.onSurface.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun TvIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
size: Int = 52,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.widthIn(min = size.dp)
.height(size.dp)
.border(if (isFocused) 2.dp else 0.dp, borderColor, RoundedCornerShape(50))
.clip(RoundedCornerShape(50))
.background(if (isFocused) scheme.primary.copy(alpha = 0.5f) else scheme.background.copy(alpha = 0.65f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground,
modifier = Modifier.padding(8.dp)
)
}
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -0,0 +1,183 @@
package hu.bbara.purefin.ui.common.card
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.Column
import androidx.compose.foundation.layout.aspectRatio
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
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 androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
fun PosterCard(
item: MediaUiModel,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocusedItem: (MediaUiModel) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
PosterCardContent(
model = item,
onClick = {
// TODO Fix it by addig a handler into the viewModel like onMediaUiModelSelected
when (item) {
is SeriesUiModel -> onSeriesSelected(item.id)
is MovieUiModel -> onMovieSelected(item.id)
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
}
},
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,
showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding,
onFocused = { onFocusedItem(item) },
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
@Composable
fun PosterCardContent(
model: MediaUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = focusedTransformOrigin
}
) {
Box {
PurefinAsyncImage(
model = model.primaryImageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
width = if (isFocused) focusedBorderWidth else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocused()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop
)
when (model) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = model.watched,
)
}
is SeriesUiModel -> {
UnwatchedEpisodeBadge(
unwatchedCount = model.unwatchedEpisodeCount,
size = 26,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
)
}
}
}
Column(
modifier = Modifier.padding(
top = 8.dp,
start = 4.dp,
end = 4.dp,
bottom = 8.dp
)
) {
Text(
text = model.primaryText,
color = scheme.onBackground,
fontSize = 13.sp,
lineHeight = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
model.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
lineHeight = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -0,0 +1,204 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 6.dp
private val MediaDetailHeaderBottomPadding = 4.dp
private const val MediaDetailBodyImageWidthFraction = 0.66f
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val trailingEdge = offset + size
return when {
offset < 0f -> offset
trailingEdge > containerSize -> trailingEdge - containerSize
else -> 0f
}
}
}
@Composable
internal fun TvMediaDetailScaffold(
resetScrollKey: Any,
modifier: Modifier = Modifier,
bodyContent: @Composable (Modifier) -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
bodyContent(contentPadding)
}
}
}
@Composable
internal fun TvMediaDetailBodyBox(
backgroundImageUrl: String,
modifier: Modifier = Modifier,
heightFraction: Float = 0.24f,
content: @Composable ColumnScope.() -> Unit
) {
val scheme = MaterialTheme.colorScheme
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val bodyHeight = screenHeight * heightFraction
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = bodyHeight)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
.align(Alignment.TopEnd)
) {
PurefinAsyncImage(
model = backgroundImageUrl,
contentDescription = null,
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
)
)
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
top = MediaDetailHeaderTopPadding,
bottom = MediaDetailHeaderBottomPadding
)
) {
content()
}
}
}
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
val primaryImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { primaryImageUrl }
}
@Composable
internal fun MediaDetailSectionTitle(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
@Composable
internal fun MediaDetailOverviewSection(
synopsis: String,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
MediaSynopsis(
synopsis = synopsis,
modifier = modifier,
title = "Overview",
titleColor = scheme.onBackground,
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
titleFontSize = 15.sp,
bodyFontSize = 11.sp,
bodyLineHeight = 14.sp,
titleSpacing = 2.dp,
collapsedLines = 1
)
}
@Composable
internal fun MediaDetailPlaybackSection(
audioTrack: String,
subtitles: String,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) {
MediaDetailSectionTitle(text = "Playback")
Spacer(modifier = Modifier.height(14.dp))
MediaPlaybackSettings(
backgroundColor = scheme.surfaceContainerHigh,
foregroundColor = scheme.onBackground,
audioTrack = audioTrack,
subtitles = subtitles
)
}
}

View File

@@ -1,12 +1,12 @@
package hu.bbara.purefin.common.ui.components
package hu.bbara.purefin.ui.common.media
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.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
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.button.GhostIconButton
internal data class MediaDetailsTopBarShortcut(
val label: String,
@@ -53,6 +54,7 @@ internal fun MediaDetailsTopBar(
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
val scheme = MaterialTheme.colorScheme
val downModifier = if (downFocusRequester != null) {
Modifier.focusProperties { down = downFocusRequester }
} else {
@@ -77,7 +79,9 @@ internal fun MediaDetailsTopBar(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack,
modifier = backModifier.then(downModifier)
modifier = backModifier.then(downModifier),
focusedScale = 1.1f,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
if (shortcut != null) {
GhostTextButton(
@@ -92,13 +96,17 @@ internal fun MediaDetailsTopBar(
icon = Icons.Outlined.Cast,
contentDescription = "Cast",
onClick = onCastClick,
modifier = downModifier
modifier = downModifier,
focusedScale = 1.1f,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
GhostIconButton(
icon = Icons.Outlined.MoreVert,
contentDescription = "More",
onClick = onMoreClick,
modifier = downModifier
modifier = downModifier,
focusedScale = 1.1f,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
)
}
}

View File

@@ -0,0 +1,144 @@
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
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.runtime.Composable
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.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
@Composable
fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val selectedDestination = backStack.lastOrNull() ?: Route.Home
val destinations = remember(libraries) {
listOf(
TvDrawerDestinationItem(
destination = Route.Home,
label = "Home",
icon = Icons.Outlined.Home
)
) + libraries.map { library ->
val destination = Route.LibraryRoute(
library = LibraryDto(id = library.id, name = library.name)
)
TvDrawerDestinationItem(
destination = destination,
label = library.name,
icon = when (library.type) {
LibraryKind.MOVIES -> Icons.Outlined.Movie
LibraryKind.SERIES -> Icons.Outlined.Tv
}
)
} + TvDrawerDestinationItem(
destination = Route.SettingsRoute,
label = "Settings",
icon = Icons.Outlined.Settings
)
}
LifecycleResumeEffect(Unit) {
viewModel.onResumed()
onPauseOrDispose { }
}
val tvEntryProvider = entryProvider {
entry<Route.Home> {
TvHomeScreen(
libraries = libraries,
libraryContent = latestLibraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaSelected = viewModel::onMediaSelected,
modifier = Modifier.fillMaxSize()
)
}
entry<Route.LibraryRoute> { route ->
TvLibraryScreen(
library = route.library,
onMediaSelected = viewModel::onMediaSelected,
modifier = Modifier.fillMaxSize()
)
}
entry<Route.SettingsRoute> {
TvSettingsScreen(
onBack = { backStack.removeLastOrNull() },
modifier = Modifier.fillMaxSize()
)
}
}
BackHandler(enabled = backStack.size == 1) {
navigationManager.pop()
}
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
if (selectedDestination == Route.Home) {
viewModel.onRefresh()
}
if (selectedDestination != destination) {
backStack.clear()
backStack.add(Route.Home)
if (destination != Route.Home) {
backStack.add(destination)
}
}
},
modifier = modifier.fillMaxSize()
) {
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
navigationManager.pop()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = tvEntryProvider,
modifier = Modifier.fillMaxSize()
)
}
}

View File

@@ -0,0 +1,122 @@
package hu.bbara.purefin.ui.screen.episode
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Composable
fun TvEpisodeScreen(
episode: EpisodeDto,
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val navigationManager = LocalNavigationManager.current
LaunchedEffect(episode) {
viewModel.selectEpisode(episode)
}
val episode = viewModel.episode.collectAsStateWithLifecycle()
val selectedEpisode = episode.value
if (selectedEpisode == null) {
PurefinWaitingScreen()
return
}
TvEpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = selectedEpisode.seriesName,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
navigationManager.navigate(
Route.PlayerRoute(mediaId = selectedEpisode.id.toString())
)
}
},
modifier = modifier
)
}
@Composable
internal fun TvEpisodeScreenContent(
episode: Episode,
seriesTitle: String?,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
withFrameNanos { }
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
resetScrollKey = episode.id,
modifier = modifier
) {
TvMediaDetailBodyBox(
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
episode.imageUrlPrefix,
ArtworkKind.PRIMARY
),
modifier = it
) {
TvEpisodeHeroSection(
episode = episode,
seriesTitle = seriesTitle,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(2.dp))
}
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(4.dp))
MediaDetailOverviewSection(
synopsis = episode.synopsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
}
Column(modifier = it.fillMaxWidth()) {
MediaDetailPlaybackSection(
audioTrack = "ENG",
subtitles = "ENG",
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
}
Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp))
// MediaCastRow(cast = episode.cast)
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View File

@@ -1,9 +1,6 @@
package hu.bbara.purefin.app.content.episode
package hu.bbara.purefin.ui.screen.episode.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -20,15 +17,16 @@ 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.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.model.Episode
internal const val EpisodePlayButtonTag = "episode-play-button"
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun EpisodeHeroSection(
internal fun TvEpisodeHeroSection(
episode: Episode,
seriesTitle: String?,
onPlay: () -> Unit,
@@ -41,63 +39,59 @@ internal fun EpisodeHeroSection(
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 760.dp)
.widthIn(max = 560.dp)
) {
if (!seriesTitle.isNullOrBlank()) {
Text(
text = seriesTitle,
color = scheme.primary,
fontSize = 18.sp,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 42.sp,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Episode ${episode.index}",
color = mutedStrong,
fontSize = 18.sp,
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(18.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = episode.releaseDate)
MediaMetaChip(text = episode.rating)
MediaMetaChip(text = episode.runtime)
MediaMetaChip(
text = episode.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.35f),
textColor = scheme.primary
)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(4.dp))
MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
highlightedItem = episode.format
)
Spacer(modifier = Modifier.height(6.dp))
MediaResumeButton(
text = episode.playButtonText(),
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
text = mediaPlayButtonText(episode.progress, episode.watched),
progress = mediaPlaybackProgress(episode.progress),
onClick = onPlay,
modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
.focusRequester(playFocusRequester)
.testTag(EpisodePlayButtonTag)
.testTag(EpisodePlayButtonTag),
focusedScale = 1.08f,
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
focusBorderWidth = 3.dp,
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
focusable = true,
height = 40.dp,
textSize = 13.sp,
iconSize = 20.dp
)
}
}
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

@@ -0,0 +1,63 @@
package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
import java.util.UUID
@Composable
fun TvHomeScreen(
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
Surface(
modifier = modifier.fillMaxSize(),
color = scheme.background
) {
Box(modifier = Modifier.fillMaxSize()) {
TvHomeHeroBackdrop(
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
)
Column(
modifier = Modifier.fillMaxSize()
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
}
}
}
}

View File

@@ -0,0 +1,165 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.widthIn
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.drawWithContent
import androidx.compose.ui.graphics.Brush
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.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
// The billboard backdrop covers the top portion of the home screen so the hero
// text sits at the bottom of the backdrop and the content rows begin below it.
internal const val TvHomeHeroHeightFraction = 0.45f
internal const val TvHomeBackdropOffset = 0.20f
/**
* The home screen background image plus the darkening gradients that keep the
* hero text readable. Drawn behind the home content and sized to the top
* [TvHomeHeroHeightFraction] of the screen.
*/
@Composable
internal fun TvHomeHeroBackdrop(
backdropImageUrl: String?,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
) {
val scheme = MaterialTheme.colorScheme
Crossfade(
targetState = backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-backdrop",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
.drawWithContent {
drawContent()
// Darken the left side so the text block stays readable over any backdrop.
drawRect(
Brush.horizontalGradient(
colorStops = arrayOf(
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.
drawRect(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f),
0.40f to scheme.background.copy(alpha = 0.10f),
0.72f to scheme.background.copy(alpha = 0.58f),
1.0f to scheme.background
)
)
)
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
@Composable
internal fun TvFocusedItemHero(
item: MediaUiModel,
modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction,
) {
val scheme = MaterialTheme.colorScheme
Crossfade(
targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
) { hero ->
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 16.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.primaryText,
color = scheme.onBackground,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
lineHeight = 44.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
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

@@ -0,0 +1,63 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp
import kotlin.math.abs
internal val TvHomeFocusedItemTopOffset = 48.dp
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
private const val TvHomeRowPivotParentFraction = 0.3f
private const val TvHomeRowPivotChildFraction = 0f
internal fun tvHomeColumnBringIntoViewSpec(topOffsetPx: Float): BringIntoViewSpec {
return object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val spaceAvailableToShowItem = containerSize - topOffsetPx
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
topOffsetPx
}
return leadingEdge - targetForLeadingEdge
}
}
}
internal val TvHomeRowBringIntoViewSpec: BringIntoViewSpec =
object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val initialTargetForLeadingEdge =
TvHomeRowPivotParentFraction * containerSize -
(TvHomeRowPivotChildFraction * childSize)
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
initialTargetForLeadingEdge
}
return leadingEdge - targetForLeadingEdge
}
}

View File

@@ -0,0 +1,108 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable
fun TvHomeContent(
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMediaFocused: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier,
) {
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty()
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
val hasVisibleContent = hasContinueWatching || hasNextUp
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
val initialFocusRequester = remember { FocusRequester() }
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(hasInitialFocusableItem, initialFocusApplied) {
if (!initialFocusApplied && hasInitialFocusableItem) {
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
}
CompositionLocalProvider(
LocalBringIntoViewSpec provides tvHomeColumnBringIntoViewSpec(topOffsetPx)
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.focusRequester(initialFocusRequester),
verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = contentPadding
) {
if (hasContinueWatching) {
item(key = "tv-home-continue-watching") {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
)
}
}
if (hasNextUp) {
item(key = "tv-home-next-up") {
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
)
}
}
itemsIndexed(
items = libraries,
key = { _, library -> library.id }
) { _, library ->
TvLibraryPosterSection(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected
)
}
if (hasVisibleContent) {
item(key = "tv-home-trailing-space") {
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,10 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.ui.graphics.vector.ImageVector
import hu.bbara.purefin.core.navigation.Route
data class TvDrawerDestinationItem(
val destination: Route,
val label: String,
val icon: ImageVector
)

View File

@@ -0,0 +1,355 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.core.animateFloatAsState
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
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.CompositionLocalProvider
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.TransformOrigin
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 androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCardContent
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.model.MediaUiModel
import java.util.UUID
import kotlin.math.roundToInt
private const val TvHomeMediaCardScale = 0.9f
private val TvHomeSectionsThumbShape = RoundedCornerShape((20f * TvHomeMediaCardScale).dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = (248f * TvHomeMediaCardScale).dp
private val TvHomePosterCardWidth = (136f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataSpacing = (1f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataTopPadding = (6f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
"${TvHomeSectionRowTagPrefix}library-$libraryId"
internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
"${TvHomeSectionRowTagPrefix}library-item-$libraryId"
@Composable
fun TvContinueWatchingSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
) { item, onFocused, focusModifier ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.primaryImageUrl,
progress = item.progress ?: 0f,
imageModifier = focusModifier,
onFocusedItem = {
onFocused(item)
},
onClick = { onMediaSelected(item) }
)
}
}
@Composable
fun TvNextUpSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Next Up",
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
) { item, onFocused, focusModifier ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.primaryImageUrl,
imageModifier = focusModifier,
onFocusedItem = { onFocused(item) },
onClick = { onMediaSelected(item) }
)
}
}
@Composable
fun TvLibraryPosterSection(
title: String,
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
onMediaSelected: (MediaUiModel) -> Unit,
) {
if (items.isEmpty()) return
TvSectionHeader(
title = title,
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
) { item, onFocused, focusModifier ->
PosterCardContent(
model = item,
onClick = { onMediaSelected(item) },
onFocused = { onFocused(item) },
posterWidth = TvHomePosterCardWidth,
showSecondaryText = true,
indicatorSize = TvHomePosterIndicatorSize,
indicatorPadding = TvHomePosterIndicatorPadding,
imageModifier = focusModifier,
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TvHomeSectionRow(
items: List<MediaUiModel>,
onFocused: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
childComponent: @Composable (
item: MediaUiModel,
onFocused: (MediaUiModel) -> Unit,
focusModifier: Modifier,
) -> Unit,
) {
val listState = rememberLazyListState()
var focusedItemId by rememberSaveable { mutableStateOf(items.first().id) }
val rowScopedFocusRequester = remember { FocusRequester() }
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
state = listState,
modifier = modifier
.fillMaxWidth()
.focusRestorer(rowScopedFocusRequester)
.focusGroup(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val focusModifier =
if (item.id == focusedItemId) Modifier.focusRequester(rowScopedFocusRequester) else Modifier
childComponent(
item,
{
focusedItemId = item.id
onFocused(item)
},
focusModifier
)
}
}
}
}
@Composable
fun TvSectionHeader(
title: String,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = TvHomeSectionsHorizontalPadding, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun TvHomeLandscapeCard(
title: String,
supporting: String,
imageUrl: String?,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
progress: Float? = null,
onFocusedItem: () -> Unit = {},
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.055f else 1f,
label = "tv-home-landscape-scale"
)
val cardWidth = TvHomeLandscapeCardWidth
Column(
modifier = modifier
.width(cardWidth)
.wrapContentHeight()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(TvHomeSectionsThumbShape)
.border(
width = if (isFocused) TvHomeLandscapeFocusedBorderWidth else TvHomeLandscapeUnfocusedBorderWidth,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
shape = TvHomeSectionsThumbShape
)
.background(scheme.surfaceContainer)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = imageModifier
.fillMaxSize()
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scheme.background.copy(alpha = 0.04f),
scheme.background.copy(alpha = 0.12f),
scheme.background.copy(alpha = 0.56f)
)
)
)
)
if (progress != null && progress > 0f) {
MediaProgressBar(
progress = progress,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = TvHomeLandscapeProgressBarHeight,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(
horizontal = TvHomeLandscapeProgressBarPadding,
vertical = TvHomeLandscapeProgressBarPadding
)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(TvHomeLandscapeMetadataSpacing),
modifier = Modifier.padding(
top = TvHomeLandscapeMetadataTopPadding,
start = TvHomeLandscapeMetadataHorizontalPadding,
end = TvHomeLandscapeMetadataHorizontalPadding
)
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = TvHomeLandscapeTitleFontSize,
lineHeight = TvHomeLandscapeTitleFontSize,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (supporting.isNotBlank()) {
Text(
text = supporting,
color = scheme.onSurfaceVariant,
fontSize = TvHomeLandscapeSupportingFontSize,
lineHeight = TvHomeLandscapeSupportingFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,11 +1,10 @@
package hu.bbara.purefin.tv.home.ui
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
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
@@ -13,47 +12,56 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
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.focus.focusProperties
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.DrawerState
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.rememberDrawerState
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.tv.R
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
internal const val TvDrawerTitleTag = "tv-drawer-title"
private val TvDrawerCollapsedWidth = 92.dp
private val TvDrawerExpandedWidth = 280.dp
@Composable
fun TvNavigationDrawer(
destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination,
onDestinationSelected: (TvDrawerDestination) -> Unit,
selectedDestination: Route,
onDestinationSelected: (Route) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
ProvideTvDrawerTheme {
val drawerState = rememberDrawerState(DrawerValue.Closed)
NavigationDrawer(
modifier = modifier.fillMaxSize(),
drawerState = drawerState,
drawerContent = {
TvNavigationDrawerRail(
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
drawerState = drawerState,
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected
@@ -122,6 +130,8 @@ fun TvDrawerHeader(
AnimatedVisibility(visible = expanded) {
Text(
text = "Purefin",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
modifier = Modifier.testTag(TvDrawerTitleTag)
)
}
@@ -130,60 +140,59 @@ fun TvDrawerHeader(
@Composable
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
drawerValue: DrawerValue,
drawerState: DrawerState,
destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination,
onDestinationSelected: (TvDrawerDestination) -> Unit,
selectedDestination: Route,
onDestinationSelected: (Route) -> Unit,
) {
val expanded = drawerValue == DrawerValue.Open
val drawerWidth = animateDpAsState(
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
label = "tv-drawer-width"
)
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
val expanded = drawerState.currentValue == DrawerValue.Open
val scheme = MaterialTheme.colorScheme
var hasDrawerFocus by remember { mutableStateOf(false) }
Box(
Column(
modifier = Modifier
.width(drawerWidth.value)
.fillMaxHeight()
.background(scheme.surface.copy(alpha = 0.96f))
.padding(horizontal = 12.dp, vertical = 24.dp)
.selectableGroup()
.focusGroup()
.onFocusChanged { state ->
hasDrawerFocus = state.isFocused
},
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 24.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TvDrawerHeader(
expanded = expanded,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
destinations.forEachIndexed { index, destination ->
val isSelected = destination.destination == selectedDestination
NavigationDrawerItem(
selected = isSelected,
onClick = { onDestinationSelected(destination.destination) },
modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected },
leadingContent = {
Icon(
imageVector = destination.icon,
contentDescription = destination.label
)
}
) {
if (expanded) {
Text(
text = destination.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
TvDrawerHeader(
expanded = expanded,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
destinations.forEachIndexed { index, destination ->
val isSelected = destination.destination == selectedDestination
NavigationDrawerItem(
selected = isSelected,
onClick = {
onDestinationSelected(destination.destination)
if (isSelected) {
drawerState.setValue(DrawerValue.Closed)
}
},
modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected }
.focusProperties { canFocus = hasDrawerFocus || isSelected },
leadingContent = {
Icon(
imageVector = destination.icon,
contentDescription = destination.label
)
}
) {
Text(
text = destination.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.weight(1f))
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.library.ui
package hu.bbara.purefin.ui.screen.libraries
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.Icon
@@ -43,9 +42,9 @@ 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 hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.LibraryItem
import org.jellyfin.sdk.model.api.CollectionType
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
@@ -53,8 +52,8 @@ private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
@Composable
fun TvLibrariesOverviewScreen(
libraries: List<LibraryItem>,
onLibrarySelected: (LibraryItem) -> Unit,
libraries: List<LibraryUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -118,7 +117,7 @@ fun TvLibrariesOverviewScreen(
@Composable
private fun TvLibraryOverviewCard(
item: LibraryItem,
item: LibraryUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -161,9 +160,8 @@ private fun TvLibraryOverviewCard(
} else {
Icon(
imageVector = when (item.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
LibraryKind.MOVIES -> Icons.Outlined.Movie
LibraryKind.SERIES -> Icons.Outlined.Tv
},
contentDescription = null,
tint = scheme.onSurfaceVariant,

View File

@@ -1,37 +1,33 @@
package hu.bbara.purefin.tv.library.ui
package hu.bbara.purefin.ui.screen.library
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
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.automirrored.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import org.jellyfin.sdk.model.UUID
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.core.model.MediaUiModel
@Composable
fun TvLibraryScreen(
library: LibraryDto,
onMediaSelected: (MediaUiModel) -> Unit,
viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
@@ -39,21 +35,19 @@ fun TvLibraryScreen(
viewModel.selectLibrary(libraryId = library.id)
}
val libraryItems = viewModel.contents.collectAsState()
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
TvLibraryTopBar(
title = library.name,
onBack = viewModel::onBack
title = library.name
)
}
) { innerPadding ->
TvLibraryContent(
libraryItems = libraryItems.value,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onMediaSelected = onMediaSelected,
modifier = Modifier.padding(innerPadding)
)
}
@@ -61,35 +55,23 @@ fun TvLibraryScreen(
@Composable
private fun TvLibraryTopBar(
title: String,
onBack: () -> Unit
title: String
) {
Row(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PurefinIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 12.dp)
)
}
.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
@Composable
fun TvLibraryContent(
libraryItems: List<PosterItem>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
libraryItems: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
@@ -100,12 +82,12 @@ fun TvLibraryContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
items(libraryItems) { item ->
items(libraryItems, key = { item -> item.id }) { item ->
PosterCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = { _, _, _ -> }
onMovieSelected = { onMediaSelected(item) },
onSeriesSelected = { onMediaSelected(item) },
onEpisodeSelected = { _, _, _ -> onMediaSelected(item) }
)
}
}

View File

@@ -0,0 +1,609 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
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.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.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
import androidx.tv.material3.Text as TvText
import androidx.tv.material3.darkColorScheme
import hu.bbara.purefin.ui.common.image.PurefinLogo
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Composable
fun TvLoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
modifier: Modifier = Modifier
) {
if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
ProvideTvLoginTheme {
TvLoginForm(
state = state,
callbacks = callbacks,
modifier = modifier
)
}
}
@Composable
private fun ProvideTvLoginTheme(content: @Composable () -> Unit) {
val scheme = MaterialTheme.colorScheme
TvMaterialTheme(
colorScheme = darkColorScheme(
primary = scheme.primary,
onPrimary = scheme.onPrimary,
primaryContainer = scheme.primaryContainer,
onPrimaryContainer = scheme.onPrimaryContainer,
secondary = scheme.secondary,
onSecondary = scheme.onSecondary,
secondaryContainer = scheme.secondaryContainer,
onSecondaryContainer = scheme.onSecondaryContainer,
tertiary = scheme.tertiary,
onTertiary = scheme.onTertiary,
tertiaryContainer = scheme.tertiaryContainer,
onTertiaryContainer = scheme.onTertiaryContainer,
background = scheme.background,
onBackground = scheme.onBackground,
surface = scheme.surface,
onSurface = scheme.onSurface,
surfaceVariant = scheme.surfaceVariant,
onSurfaceVariant = scheme.onSurfaceVariant,
surfaceTint = scheme.surfaceTint,
inverseSurface = scheme.inverseSurface,
inverseOnSurface = scheme.inverseOnSurface,
error = scheme.error,
onError = scheme.onError,
errorContainer = scheme.errorContainer,
onErrorContainer = scheme.onErrorContainer,
border = scheme.outline,
borderVariant = scheme.outlineVariant,
scrim = scheme.scrim
),
content = content
)
}
@Composable
private fun TvLoginForm(
state: LoginContentState,
callbacks: LoginContentCallbacks,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val serverFocusRequester = remember { FocusRequester() }
val findFocusRequester = remember { FocusRequester() }
val changeServerFocusRequester = remember { FocusRequester() }
val quickConnectFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
val connectFocusRequester = remember { FocusRequester() }
LaunchedEffect(state.phase) {
if (state.phase == LoginContentPhase.ServerSearch) {
serverFocusRequester.requestFocus()
} else if (state.quickConnectAvailable) {
quickConnectFocusRequester.requestFocus()
} else {
usernameFocusRequester.requestFocus()
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(horizontal = 64.dp, vertical = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.widthIn(max = if (state.phase == LoginContentPhase.Login) 1040.dp else 480.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
PurefinLogo(
color = scheme.primary,
modifier = Modifier.size(64.dp)
)
Text(
text = "Purefin",
color = scheme.onBackground,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = if (state.phase == LoginContentPhase.ServerSearch) {
"Find your Jellyfin server"
} else {
"Connect to your media server"
},
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(20.dp))
state.errorMessage?.let { errorMessage ->
Text(
text = errorMessage,
color = scheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.background(
color = scheme.errorContainer,
shape = RoundedCornerShape(14.dp)
)
.padding(horizontal = 14.dp, vertical = 10.dp)
)
Spacer(modifier = Modifier.height(10.dp))
}
when (state.phase) {
LoginContentPhase.ServerSearch -> TvServerSearchFields(
state = state,
callbacks = callbacks,
serverFocusRequester = serverFocusRequester,
findFocusRequester = findFocusRequester
)
LoginContentPhase.Login -> TvLoginFields(
state = state,
callbacks = callbacks,
changeServerFocusRequester = changeServerFocusRequester,
quickConnectFocusRequester = quickConnectFocusRequester,
usernameFocusRequester = usernameFocusRequester,
passwordFocusRequester = passwordFocusRequester,
connectFocusRequester = connectFocusRequester
)
}
}
}
}
@Composable
private fun TvServerSearchFields(
state: LoginContentState,
callbacks: LoginContentCallbacks,
serverFocusRequester: FocusRequester,
findFocusRequester: FocusRequester
) {
val scheme = MaterialTheme.colorScheme
TvLoginTextField(
label = "Server URL",
value = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage,
modifier = Modifier
.focusRequester(serverFocusRequester)
.focusProperties {
down = findFocusRequester
}
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = callbacks.onFindServer,
enabled = !state.isSearching,
modifier = Modifier
.focusRequester(findFocusRequester)
.focusProperties {
up = serverFocusRequester
}
.fillMaxWidth()
.height(48.dp)
) {
TvText(
text = if (state.isSearching) "Searching..." else "Find server",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
if (state.discoveredServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(14.dp))
Text(
text = "Nearby servers",
color = scheme.onBackground,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
state.discoveredServers.take(3).forEach { server ->
Button(
onClick = { callbacks.onDiscoveredServerClick(server) },
modifier = Modifier.fillMaxWidth().height(54.dp)
) {
TvText(
text = server.name ?: server.address,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 15.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun TvLoginFields(
state: LoginContentState,
callbacks: LoginContentCallbacks,
changeServerFocusRequester: FocusRequester,
quickConnectFocusRequester: FocusRequester,
usernameFocusRequester: FocusRequester,
passwordFocusRequester: FocusRequester,
connectFocusRequester: FocusRequester
) {
val scheme = MaterialTheme.colorScheme
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Selected server",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = selectedServer,
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = callbacks.onChangeServer,
modifier = Modifier
.focusRequester(changeServerFocusRequester)
.focusProperties {
down = if (state.quickConnectAvailable) quickConnectFocusRequester else usernameFocusRequester
}
.width(176.dp)
.height(44.dp)
) {
TvText(text = "Change server", fontSize = 15.sp)
}
}
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
TvLoginOptionPanel(
title = "Quick Connect",
modifier = Modifier.weight(1f)
) {
if (state.quickConnectAvailable) {
val quickConnectCode = state.quickConnectCode
if (quickConnectCode != null) {
Text(
text = quickConnectCode,
color = scheme.primary,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Approve this code in another Jellyfin client.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Text(
text = "Use another Jellyfin client to approve this TV without entering your password.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
}
Button(
onClick = if (state.quickConnectCode == null) {
callbacks.onQuickConnect
} else {
callbacks.onCancelQuickConnect
},
enabled = state.quickConnectCode != null || !state.isQuickConnecting,
modifier = Modifier
.focusRequester(quickConnectFocusRequester)
.focusProperties {
up = changeServerFocusRequester
right = usernameFocusRequester
down = usernameFocusRequester
}
.fillMaxWidth()
.height(48.dp)
) {
TvText(
text = if (state.quickConnectCode == null) {
"Quick Connect"
} else {
"Cancel Quick Connect"
},
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
} else {
Text(
text = "Quick Connect is not enabled on this server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
TvLoginOptionPanel(
title = "Manual login",
modifier = Modifier.weight(1f)
) {
TvLoginTextField(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person,
modifier = Modifier
.focusRequester(usernameFocusRequester)
.focusProperties {
up = changeServerFocusRequester
left = if (state.quickConnectAvailable) {
quickConnectFocusRequester
} else {
FocusRequester.Default
}
down = passwordFocusRequester
}
)
Spacer(modifier = Modifier.height(12.dp))
TvLoginPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "Enter your password",
leadingIcon = Icons.Default.Lock,
modifier = Modifier
.focusRequester(passwordFocusRequester)
.focusProperties {
up = usernameFocusRequester
down = connectFocusRequester
}
)
Spacer(modifier = Modifier.height(18.dp))
Button(
onClick = callbacks.onConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier
.focusRequester(connectFocusRequester)
.focusProperties {
up = passwordFocusRequester
}
.fillMaxWidth()
.height(48.dp)
) {
TvText(
text = "Connect",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun TvLoginOptionPanel(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(18.dp)
Column(
modifier = modifier
.clip(shape)
.background(scheme.surface)
.border(
width = 1.dp,
color = scheme.outlineVariant.copy(alpha = 0.45f),
shape = shape
)
.padding(22.dp)
) {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(18.dp))
content()
}
}
@Composable
private fun TvLoginTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
modifier: Modifier = Modifier,
trailingIcon: (@Composable () -> Unit)? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(14.dp)
var isFocused by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = scheme.onBackground,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 6.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.4f),
shape = shape
)
.clip(shape),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
placeholder = {
Text(
text = placeholder,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
leadingIcon = {
Icon(
imageVector = leadingIcon,
contentDescription = null,
tint = scheme.onSurfaceVariant
)
},
trailingIcon = trailingIcon,
visualTransformation = visualTransformation,
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceContainer,
unfocusedContainerColor = scheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
)
)
}
}
@Composable
private fun TvLoginPasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isPasswordVisible by remember { mutableStateOf(false) }
TvLoginTextField(
label = label,
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
leadingIcon = leadingIcon,
modifier = modifier,
trailingIcon = {
IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) {
Icon(
imageVector = if (isPasswordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (isPasswordVisible) "Hide password" else "Show password",
tint = scheme.onSurfaceVariant
)
}
},
visualTransformation = if (isPasswordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
}
)
}

View File

@@ -0,0 +1,98 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun TvLoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.url.collectAsStateWithLifecycle()
val phase by viewModel.phase.collectAsStateWithLifecycle()
val selectedServer by viewModel.selectedServer.collectAsStateWithLifecycle()
val discoveredServers by viewModel.discoveredServers.collectAsStateWithLifecycle()
val username by viewModel.username.collectAsStateWithLifecycle()
val password by viewModel.password.collectAsStateWithLifecycle()
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val isLoggingIn by viewModel.isLoggingIn.collectAsStateWithLifecycle()
val quickConnectAvailable by viewModel.quickConnectAvailable.collectAsStateWithLifecycle()
val quickConnectCode by viewModel.quickConnectCode.collectAsStateWithLifecycle()
val isQuickConnecting by viewModel.isQuickConnecting.collectAsStateWithLifecycle()
val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
val state = remember(
phase,
serverUrl,
selectedServer,
discoveredServers,
username,
password,
isSearching,
isLoggingIn,
quickConnectAvailable,
quickConnectCode,
isQuickConnecting,
errorMessage
) {
LoginContentState(
phase = if (phase == LoginViewModel.Phase.LOGIN) {
LoginContentPhase.Login
} else {
LoginContentPhase.ServerSearch
},
serverUrl = serverUrl,
selectedServerName = selectedServer?.name,
selectedServerUrl = selectedServer?.address,
discoveredServers = discoveredServers.map {
LoginServerCandidateUi(name = it.name, address = it.address)
},
username = username,
password = password,
isSearching = isSearching,
isLoggingIn = isLoggingIn,
quickConnectAvailable = quickConnectAvailable,
quickConnectCode = quickConnectCode,
isQuickConnecting = isQuickConnecting,
errorMessage = errorMessage
)
}
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = viewModel::setUrl,
onFindServer = viewModel::findServer,
onDiscoveredServerClick = {
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
},
onChangeServer = viewModel::changeServer,
onUsernameChange = {
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
viewModel.login()
}
},
onQuickConnect = viewModel::startQuickConnect,
onCancelQuickConnect = viewModel::cancelQuickConnect
)
}
TvLoginContent(
state = state,
callbacks = callbacks,
modifier = modifier
)
}

View File

@@ -0,0 +1,84 @@
package hu.bbara.purefin.ui.screen.movie
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Composable
fun TvMovieScreen(
movie: MovieDto,
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(movie) {
viewModel.selectMovie(movie)
}
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
if (movieItem.value != null) {
TvMovieScreenContent(
movie = movieItem.value!!,
onPlay = viewModel::onPlay,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}
@Composable
internal fun TvMovieScreenContent(
movie: Movie,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) {
withFrameNanos { }
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
resetScrollKey = movie.id,
modifier = modifier
) {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
modifier = Modifier.fillMaxSize(),
heightFraction = 1f
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MediaDetailHorizontalPadding)
) {
TvMovieHeroSection(
movie = movie,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

View File

@@ -0,0 +1,111 @@
package hu.bbara.purefin.ui.screen.movie.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.model.Movie
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
internal const val MoviePlayButtonTag = "movie-play-button"
@Composable
internal fun TvMovieHeroSection(
movie: Movie,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
Column(
modifier = modifier
.padding(top = 24.dp)
.fillMaxWidth()
) {
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
MediaMetadataFlowRow(
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
highlightedItem = movie.format
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Overview",
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = movie.synopsis,
color = mutedStrong,
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.4f)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Playback",
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "ENG • Subtitles ENG",
color = mutedStrong,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(12.dp))
MediaResumeButton(
text = mediaPlayButtonText(movie.progress, movie.watched),
progress = mediaPlaybackProgress(movie.progress),
onClick = onPlay,
modifier = Modifier
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
.focusRequester(playFocusRequester)
.testTag(MoviePlayButtonTag),
focusedScale = 1.08f,
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
focusBorderWidth = 3.dp,
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
focusable = true,
height = 40.dp,
textSize = 13.sp,
iconSize = 20.dp
)
}
}

View File

@@ -0,0 +1,566 @@
package hu.bbara.purefin.ui.screen.player
import android.app.Activity
import android.view.WindowManager
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.SkipNext
import androidx.compose.material3.Icon
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.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
import androidx.compose.ui.draw.clip
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.onKeyEvent
import androidx.compose.ui.input.key.type
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.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.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.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
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@OptIn(UnstableApi::class)
@Composable
fun TvPlayerScreen(
mediaId: String,
viewModel: PlayerViewModel = hiltViewModel(),
onBack: () -> Unit
) {
LaunchedEffect(mediaId) {
viewModel.loadMedia(mediaId)
}
val scope = rememberCoroutineScope()
var hideControlsJob: Job? by remember { mutableStateOf(null) }
val uiState by viewModel.uiState.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) }
// This is a hack for timed visibility.
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
val context = LocalContext.current
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) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
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()
}
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
fun expandPlaylist() {
// TODO: focus management for playlist expansion
isPlaylistExpanded = true
}
fun closePlaylist() {
// TODO focus
isPlaylistExpanded = false
}
val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType
trackPanelType = null
}
}
fun handleBack() {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> closePlaylist()
controlsVisible -> hideControls()
else -> onBack()
}
}
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
}
showNextEpisodeOverlay -> {
nextEpisodeFocusRequester
}
else -> {
backgroundFocusRequester
}
}
if (!controlsVisible) {
rootFocusRequester.requestFocus()
}
}
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
}
val subtitleBottomPaddingFraction =
if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(rootFocusRequester)
.onKeyEvent { event ->
if (event.key == Key.Back) {
if (event.type == KeyEventType.KeyDown) {
handleBack()
}
true
} else {
val handled = handleTvPlayerRootKeyEvent(
event = event,
controlsVisible = controlsVisible,
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onShowControls = ::showControls,
onTogglePlayback = {
resumeStopFeedbackCounter++
viewModel.togglePlayPause()
},
onSeekRelative = {
hiddenSeekCounter++
viewModel.seekBy(it)
},
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
}
handled
}
}
.focusable()
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
isFocusable = false
isFocusableInTouchMode = false
}
},
update = {
it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
},
modifier = Modifier
.fillMaxHeight()
.align(Alignment.Center)
)
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
TvPlayerControlsOverlay(
modifier = Modifier.fillMaxSize(),
uiState = uiState,
focusRequester = controlsFocusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = { viewModel.togglePlayPause() },
onSeek = { positionMs ->
viewModel.seekTo(positionMs)
},
onSeekRelative = { deltaMs ->
viewModel.seekBy(deltaMs)
},
onSkipSegment = {
viewModel.skipActiveSegment()
},
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = ::expandPlaylist,
onCollapsePlaylist = ::closePlaylist,
onSelectQueueItem = { id ->
viewModel.playQueueItem(id)
},
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
)
}
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
TvPlayerClock()
}
AnimatedVisibility(
visible = showSkipIntroButton,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 24.dp,
bottom = 24.dp
)
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = { viewModel.skipActiveSegment() },
size = 64,
label = "Skip",
modifier = Modifier.focusRequester(skipButtonFocusRequester)
)
}
AnimatedVisibility(
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)
)
}
}
if (!showSkipIntroButton && !controlsVisible) {
ValueChangeTimedVisibility(
value = hiddenSeekCounter,
hideAfterMillis = 2500L,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 32.dp, vertical = 28.dp)
) {
HiddenTvSeekTimeline(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads
)
}
}
TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center),
uiState = uiState,
onRetry = { viewModel.retry() },
onNext = { viewModel.next() },
onReplay = {
viewModel.seekTo(0L)
viewModel.resumePlayback()
},
onDismissError = { viewModel.clearError() }
)
ValueChangeTimedVisibility(
value = resumeStopFeedbackCounter,
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
modifier = Modifier.align(Alignment.Center)
) {
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
}
AnimatedVisibility(
visible = trackPanelType != null,
enter = slideInHorizontally { it },
exit = slideOutHorizontally { it }
) {
trackPanelType?.let { panelType ->
TvTrackSelectionPanel(
panelType = panelType,
uiState = uiState,
onSelect = { track ->
viewModel.selectTrack(track)
closeTrackPanel()
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@Composable
private fun HiddenTvSeekTimeline(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
modifier: Modifier = Modifier
) {
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Transparent)
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
TvPlayerTimeRow(
positionMs = positionMs,
durationMs = durationMs,
modifier = Modifier.fillMaxWidth()
)
PlayerSeekBarTrack(
positionMs = positionMs,
durationMs = safeDuration,
bufferedMs = bufferedMs,
chapterMarkers = chapterMarkers,
adMarkers = adMarkers,
modifier = Modifier
.fillMaxWidth()
.height(32.dp),
isFocused = false,
thumbRadius = 7.dp,
focusedThumbRadius = 9.dp,
focusedThumbHaloRadiusDelta = 0.dp
)
}
}
@Composable
private fun TvPlayerClock(modifier: Modifier = Modifier) {
var currentTime by remember { mutableStateOf(LocalTime.now()) }
LaunchedEffect(Unit) {
while (true) {
currentTime = LocalTime.now()
delay(1_000L)
}
}
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
Text(
text = formatter.format(currentTime),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
internal fun handleTvPlayerRootKeyEvent(
event: KeyEvent,
controlsVisible: Boolean,
popupVisible: Boolean,
onTogglePlayback: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
if (!controlsVisible) {
return when (event.key) {
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(30_000)
true
}
Key.DirectionUp, Key.DirectionDown -> {
onShowControls()
true
}
Key.DirectionCenter, Key.Enter -> {
if (popupVisible) {
// Do nothing because the focused component is not he root, but an overlay
false
} else {
onTogglePlayback()
true
}
}
else -> false
}
}
return false
}
@Composable
internal fun TvPlayerStopFeedback(
stopped: Boolean,
modifier: Modifier = Modifier
) {
if (!stopped) {
return
}
Box(
modifier = modifier
.testTag(TvPlayerHiddenStopFeedbackTag)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.72f))
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Pause,
contentDescription = "Play/Pause playback",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(72.dp)
)
}
}

View File

@@ -0,0 +1,127 @@
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.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
internal fun TvIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
size: Int = 52,
enabled: Boolean = true,
label: String? = null,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.1f else 1.0f,
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary else Color.Transparent,
label = "border"
)
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) {
scheme.primaryContainer
} else {
scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "background"
)
val contentColor by animateColorAsState(
targetValue = if (isFocused) scheme.onPrimaryContainer else scheme.onSurface,
label = "content"
)
val shape = RoundedCornerShape(50)
val iconSize = (size - 24).coerceAtLeast(0).dp
val buttonModifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.alpha(if (enabled) 1f else 0.4f)
.border(
width = if (isFocused) 2.dp else 0.dp,
color = borderColor,
shape = shape
)
.clip(shape)
.background(backgroundColor)
.focusProperties { canFocus = enabled }
.semantics {
if (!enabled) {
disabled()
}
}
.onFocusChanged { isFocused = it.isFocused }
.clickable(enabled = enabled) { onClick() }
if (label == null) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = contentColor,
modifier = buttonModifier
.size(size.dp)
.padding(12.dp)
.size(iconSize)
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = buttonModifier
.widthIn(min = 104.dp)
.height(size.dp)
.padding(horizontal = 16.dp)
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = contentColor,
modifier = Modifier.size(iconSize)
)
Text(
text = label,
color = contentColor,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}

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

@@ -0,0 +1,372 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.Spacer
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.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.material.icons.outlined.SkipPrevious
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.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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
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.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
internal const val TvPlayerSeekBarTag = "tv_player_seek_bar"
@Composable
internal fun TvPlayerControlsOverlay(
uiState: PlayerUiState,
focusRequester: FocusRequester,
isPlaylistExpanded: Boolean,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSkipSegment: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(
Color.Black.copy(alpha = 0.5f),
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
) {
TvPlayerTopBar(
title = uiState.title ?: "Playing",
subtitle = uiState.subtitle,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
TvPlayerBottomSection(
uiState = uiState,
focusRequester = focusRequester,
isPlaylistExpanded = isPlaylistExpanded,
qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = onPlayPause,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
onSkipSegment = onSkipSegment,
onNext = onNext,
onPrevious = onPrevious,
onOpenAudioPanel = onOpenAudioPanel,
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
onOpenQualityPanel = onOpenQualityPanel,
onExpandPlaylist = onExpandPlaylist,
onCollapsePlaylist = onCollapsePlaylist,
onSelectQueueItem = onSelectQueueItem,
qualityButtonEnabled = qualityButtonEnabled,
audioButtonEnabled = audioButtonEnabled,
subtitlesButtonEnabled = subtitlesButtonEnabled,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
}
@Composable
private fun TvPlayerTopBar(
title: String,
subtitle: String?,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (subtitle != null) {
Text(
text = subtitle,
color = scheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
private fun TvPlayerBottomSection(
uiState: PlayerUiState,
focusRequester: FocusRequester,
isPlaylistExpanded: Boolean,
qualityButtonFocusRequester: FocusRequester,
audioButtonFocusRequester: FocusRequester,
subtitlesButtonFocusRequester: FocusRequester,
onPlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
onSkipSegment: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit,
onOpenSubtitlesPanel: () -> Unit,
onOpenQualityPanel: () -> Unit,
onExpandPlaylist: () -> Unit,
onCollapsePlaylist: () -> Unit,
onSelectQueueItem: (String) -> Unit,
qualityButtonEnabled: Boolean,
audioButtonEnabled: Boolean,
subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier
) {
val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
LaunchedEffect(isPlaylistExpanded) {
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
playlistFocusRequester.requestFocus()
} else if (wasPlaylistExpanded) {
focusRequester.requestFocus()
}
wasPlaylistExpanded = isPlaylistExpanded
}
val playlistExpandState = if (isPlaylistExpanded) "expanded" else "collapsed"
val expandPlaylistModifier = Modifier.onPreviewKeyEvent { event ->
handleExpandPlaylistKey(event, onExpandPlaylist)
}
Column(
modifier = modifier
.testTag(TvPlayerPlaylistStateTag)
.semantics { stateDescription = playlistExpandState }
) {
TvPlayerTimeRow(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
modifier = Modifier.padding(horizontal = 4.dp)
)
TvPlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
onSeek = onSeek,
onSeekRelative = onSeekRelative,
togglePlayState = onPlayPause,
onMoveDown = {
focusRequester.requestFocus()
true
},
modifier = Modifier.testTag(TvPlayerSeekBarTag)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
) {
Row(
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous",
onClick = onPrevious,
size = 56,
modifier = expandPlaylistModifier
)
TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
onClick = onPlayPause,
size = 64,
modifier = expandPlaylistModifier
.focusRequester(focusRequester)
.testTag(TvPlayerPlayPauseButtonTag)
)
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Next",
onClick = onNext,
size = 56,
modifier = expandPlaylistModifier
)
}
Row(
modifier = Modifier.align(Alignment.CenterStart),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvQualitySelectionButton(
onClick = onOpenQualityPanel,
enabled = qualityButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(qualityButtonFocusRequester)
.testTag(TvPlayerQualityButtonTag)
)
TvAudioSelectionButton(
onClick = onOpenAudioPanel,
enabled = audioButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(audioButtonFocusRequester)
.testTag(TvPlayerAudioButtonTag)
)
TvSubtitlesSelectionButton(
onClick = onOpenSubtitlesPanel,
enabled = subtitlesButtonEnabled,
modifier = expandPlaylistModifier
.focusRequester(subtitlesButtonFocusRequester)
.testTag(TvPlayerSubtitlesButtonTag)
)
}
if (uiState.activeSkippableSegmentEndMs != null) {
Row(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = onSkipSegment,
size = 64,
label = "Skip",
modifier = expandPlaylistModifier
)
}
}
}
AnimatedVisibility(
visible = isPlaylistExpanded,
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)
) {
TvPlayerQueuePanel(
uiState = uiState,
firstItemFocusRequester = playlistFocusRequester,
onSelect = onSelectQueueItem,
onReturnToControls = onCollapsePlaylist,
modifier = Modifier
.fillMaxWidth()
.padding(top = 18.dp)
)
}
Spacer(modifier = Modifier.height(if (isPlaylistExpanded) 12.dp else 8.dp))
}
}
@Composable
internal fun TvPlayerTimeRow(
positionMs: Long,
durationMs: Long,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatTime(durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
private fun handleExpandPlaylistKey(
event: androidx.compose.ui.input.key.KeyEvent,
onExpand: () -> Unit
): Boolean {
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
onExpand()
return true
}
return false
}
private fun formatTime(positionMs: Long): String {
val totalSeconds = positionMs / 1000
val seconds = (totalSeconds % 60).toInt()
val minutes = ((totalSeconds / 60) % 60).toInt()
val hours = (totalSeconds / 3600).toInt()
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%02d:%02d".format(minutes, seconds)
}
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
internal fun TvPlayerLoadingErrorEndCard(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
PlayerLoadingErrorEndCardContent(
uiState = uiState,
onRetry = onRetry,
onNext = onNext,
onReplay = onReplay,
onDismissError = onDismissError,
modifier = modifier,
showBufferWhileError = false,
cardPadding = 24.dp,
cardSpacing = 16.dp,
backgroundAlpha = 0.92f,
headlineStyle = MaterialTheme.typography.titleMedium
)
}

View File

@@ -0,0 +1,309 @@
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.runtime.withFrameNanos
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.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.input.key.Key
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.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 coil3.compose.AsyncImage
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
internal const val TvPlayerPlaylistFirstItemTag = "tv_player_playlist_first_item"
internal const val TvPlayerPlaylistLastItemTag = "tv_player_playlist_last_item"
@Composable
internal fun TvPlayerQueuePanel(
uiState: PlayerUiState,
firstItemFocusRequester: FocusRequester,
onSelect: (String) -> Unit,
onReturnToControls: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val currentIndex = uiState.queue.indexOfFirst { it.isCurrent }
val entryIndex = if (currentIndex >= 0) currentIndex else 0
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
)
val queueCountLabel = when (uiState.queue.size) {
0 -> "No items in queue"
1 -> "1 item in queue"
else -> "${uiState.queue.size} items in queue"
}
LaunchedEffect(uiState.queue, entryIndex) {
if (uiState.queue.isNotEmpty()) {
withFrameNanos { }
firstItemFocusRequester.requestFocus()
}
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(24.dp),
color = scheme.surface.copy(alpha = 0.94f)
) {
if (uiState.queue.isEmpty()) {
Text(
text = "Add something to the queue to browse it here.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
} else {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.testTag(TvPlayerPlaylistRowTag),
state = listState,
horizontalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(16.dp)
) {
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
val isEntryItem = index == entryIndex
TvQueueRowCard(
item = item,
isCurrent = item.isCurrent,
isFirst = index == 0,
isLast = index == uiState.queue.lastIndex,
onClick = { onSelect(item.id) },
onReturnToControls = onReturnToControls,
modifier = Modifier
.width(228.dp)
.then(
if (isEntryItem) {
Modifier
.focusRequester(firstItemFocusRequester)
.testTag(TvPlayerPlaylistCurrentItemTag)
} else {
Modifier
}
)
.then(
if (index == 0 && !isEntryItem) {
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
} else {
Modifier
}
)
.then(
if (index == uiState.queue.lastIndex && !isEntryItem) {
Modifier.testTag(TvPlayerPlaylistLastItemTag)
} else {
Modifier
}
)
)
}
}
}
}
}
@Composable
private fun TvQueueRowCard(
item: PlaylistElementUiModel,
isCurrent: Boolean,
isFirst: Boolean,
isLast: Boolean,
onClick: () -> Unit,
onReturnToControls: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.06f else 1f,
label = "queueCardScale"
)
val borderColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primary
isCurrent -> scheme.tertiary
else -> scheme.outlineVariant.copy(alpha = 0.5f)
},
label = "queueCardBorder"
)
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primaryContainer.copy(alpha = 0.96f)
isCurrent -> scheme.tertiaryContainer.copy(alpha = 0.86f)
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "queueCardBackground"
)
val contentColor = when {
isFocused -> scheme.onPrimaryContainer
isCurrent -> scheme.onTertiaryContainer
else -> scheme.onSurface
}
val shape = RoundedCornerShape(18.dp)
Column(
modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.border(
width = when {
isFocused -> 3.dp
isCurrent -> 2.dp
else -> 1.dp
},
color = borderColor,
shape = shape
)
.clip(shape)
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.DirectionUp -> {
onReturnToControls()
true
}
Key.DirectionLeft -> isFirst
Key.DirectionRight -> isLast
else -> false
}
}
}
.clickable { onClick() }
.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
TvQueueArtwork(
artworkUrl = item.artworkUrl,
isCurrent = isCurrent,
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
modifier = Modifier.fillMaxWidth()
)
Text(
text = item.title,
color = contentColor,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
@Composable
private fun TvQueueArtwork(
artworkUrl: String?,
isCurrent: Boolean,
badgeColor: Color,
badgeContentColor: Color,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val placeholderPainter = ColorPainter(scheme.surfaceVariant)
Box(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(scheme.surfaceContainerHigh)
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl.takeIf { it.isNotEmpty() },
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
contentScale = ContentScale.Crop,
placeholder = placeholderPainter,
error = placeholderPainter,
fallback = placeholderPainter
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainerHigh)
)
}
if (isCurrent) {
Row(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(50))
.background(badgeColor)
.padding(horizontal = 9.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.PlayArrow,
contentDescription = null,
tint = badgeContentColor,
modifier = Modifier.size(14.dp)
)
Text(
text = "Now playing",
color = badgeContentColor,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
}
}
}

View File

@@ -0,0 +1,96 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
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.unit.dp
import hu.bbara.purefin.core.player.model.TimedMarker
@Composable
internal fun TvPlayerSeekBar(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit,
togglePlayState: () -> Unit,
onMoveDown: (() -> Boolean)? = null,
focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier
) {
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
val position = positionMs.coerceIn(0, safeDuration)
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.height(32.dp)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
Key.DirectionDown -> onMoveDown?.invoke() ?: false
Key.DirectionLeft -> {
onSeekRelative(-10_000)
true
}
Key.DirectionRight -> {
onSeekRelative(10_000)
true
}
Key.DirectionCenter,
Key.Enter,
Key.NumPadEnter -> {
togglePlayState()
true
}
else -> false
}
} else {
false
}
}
.focusable(),
contentAlignment = Alignment.Center
) {
PlayerSeekBarTrack(
positionMs = position,
durationMs = safeDuration,
bufferedMs = bufferedMs,
chapterMarkers = chapterMarkers,
adMarkers = adMarkers,
modifier = Modifier.fillMaxSize(),
isFocused = isFocused,
thumbRadius = 7.dp,
focusedThumbRadius = 9.dp,
focusedThumbHaloRadiusDelta = 4.dp
)
}
}

View File

@@ -0,0 +1,292 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.animateColorAsState
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.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.runtime.withFrameNanos
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.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
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.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.TrackOption
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
internal const val TvPlayerSubtitlesButtonTag = "tv_player_subtitles_button"
internal const val TvPlayerTrackPanelTag = "tv_player_track_panel"
internal const val TvPlayerTrackSelectedItemTag = "tv_player_track_selected_item"
internal const val TvPlayerTrackFirstItemTag = "tv_player_track_first_item"
internal const val TvPlayerTrackLastItemTag = "tv_player_track_last_item"
internal enum class TvTrackPanelType {
AUDIO,
SUBTITLES,
QUALITY
}
@Composable
internal fun TvQualitySelectionButton(
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.HighQuality,
contentDescription = "Quality",
onClick = onClick,
enabled = enabled,
modifier = modifier
)
}
@Composable
internal fun TvAudioSelectionButton(
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.Language,
contentDescription = "Audio",
onClick = onClick,
enabled = enabled,
modifier = modifier
)
}
@Composable
internal fun TvSubtitlesSelectionButton(
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
TvIconButton(
icon = Icons.Outlined.ClosedCaption,
contentDescription = "Subtitles",
onClick = onClick,
enabled = enabled,
modifier = modifier
)
}
@Composable
internal fun TvTrackSelectionPanel(
panelType: TvTrackPanelType,
uiState: PlayerUiState,
onSelect: (TrackOption) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val (title, options, selectedId) = when (panelType) {
TvTrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
}
val entryIndex = options.indexOfFirst { it.id == selectedId }.takeIf { it >= 0 } ?: 0
val focusRequesters = remember(options.map(TrackOption::id)) {
options.map { FocusRequester() }
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
)
LaunchedEffect(entryIndex, focusRequesters) {
withFrameNanos { }
focusRequesters.getOrNull(entryIndex)?.requestFocus()
}
Box(
modifier = modifier
.fillMaxSize()
.testTag(TvPlayerTrackPanelTag),
contentAlignment = Alignment.CenterEnd
) {
Surface(
modifier = Modifier
.fillMaxHeight()
.width(320.dp)
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
color = scheme.surface.copy(alpha = 0.97f)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
color = scheme.onSurface,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
LazyColumn(
modifier = Modifier
.heightIn(max = 500.dp)
.fillMaxWidth(),
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
itemsIndexed(options, key = { _, option -> option.id }) { index, option ->
val entryItemTag = when {
selectedId == null && index == entryIndex -> TvPlayerTrackFirstItemTag
index == entryIndex -> TvPlayerTrackSelectedItemTag
index == 0 -> TvPlayerTrackFirstItemTag
index == options.lastIndex -> TvPlayerTrackLastItemTag
else -> null
}
TvTrackOptionRow(
modifier = Modifier
.then(
if (index == entryIndex) {
Modifier
.focusRequester(focusRequesters[index])
} else {
Modifier
}
)
.then(
if (entryItemTag != null) {
Modifier.testTag(entryItemTag)
} else {
Modifier
}
),
label = option.label,
selected = option.id == selectedId,
isFirst = index == 0,
isLast = index == options.lastIndex,
onClick = { onSelect(option) }
)
}
}
}
}
}
}
@Composable
private fun TvTrackOptionRow(
modifier: Modifier = Modifier,
label: String,
selected: Boolean,
isFirst: Boolean,
isLast: Boolean,
onClick: () -> Unit
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primaryContainer
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
},
label = "trackOptionBackground"
)
val contentColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.onPrimaryContainer
selected -> scheme.onSecondaryContainer
else -> scheme.onSurface
},
label = "trackOptionContent"
)
val borderColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primary
selected -> scheme.secondary
else -> scheme.outlineVariant.copy(alpha = 0.45f)
},
label = "trackOptionBorder"
)
val shape = RoundedCornerShape(12.dp)
Box(
modifier = modifier
.fillMaxWidth()
.border(
width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
color = borderColor,
shape = shape
)
.clip(shape)
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
false
} else {
when (event.key) {
Key.DirectionLeft, Key.DirectionRight -> true
Key.DirectionUp -> isFirst
Key.DirectionDown -> isLast
else -> false
}
}
}
.clickable { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = contentColor,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
)
if (selected) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(22.dp)
)
}
}
}
}

View File

@@ -0,0 +1,172 @@
package hu.bbara.purefin.ui.screen.series
import androidx.compose.foundation.layout.Column
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.padding
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.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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable
fun TvSeriesScreen(
series: SeriesDto,
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
LaunchedEffect(series) {
viewModel.selectSeries(series)
}
val series = viewModel.series.collectAsStateWithLifecycle()
val seriesData = series.value
if (seriesData != null) {
TvSeriesScreenContent(
series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode,
focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId,
modifier = modifier
)
} else {
PurefinWaitingScreen()
}
}
@Composable
internal fun TvSeriesScreenContent(
series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit,
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
) {
val defaultSeason = series.defaultSeason(focusedSeasonId)
var selectedSeasonId by remember(series.id, focusedSeasonId) {
mutableStateOf(defaultSeason?.id)
}
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
val initialFocusSeasonId = defaultSeason?.id
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
series.allSeasons.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() == true &&
initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { selectSeason(series.id, it.id) }
}
TvMediaDetailScaffold(
resetScrollKey = series.id,
modifier = modifier
) {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
modifier = Modifier.fillMaxSize(),
heightFraction = 1f
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MediaDetailHorizontalPadding)
) {
TvSeriesHeroSection(
series = series,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (selectedSeason != null) {
TvSeasonTabs(
seasons = series.allSeasons,
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? {
if (focusedSeasonId != null) {
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
}
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
}
private fun Season.nextUpEpisode(): Episode? {
return episodes.firstOrNull { !it.watched } ?: episodes.firstOrNull()
}
private fun Season.focusTargetEpisodeId(focusedEpisodeId: UUID?): UUID? {
val focusedEpisode = episodes.firstOrNull { it.id == focusedEpisodeId }
return focusedEpisode?.id ?: nextUpEpisode()?.id
}

View File

@@ -0,0 +1,445 @@
package hu.bbara.purefin.ui.screen.series.components
import androidx.compose.animation.core.animateFloatAsState
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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
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
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.runtime.withFrameNanos
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
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.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
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.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.screen.home.components.TvHomeRowBringIntoViewSpec
import java.util.UUID
import androidx.tv.material3.Text as TvText
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
@Composable
internal fun TvSeriesMetaChips(series: Series) {
MediaMetadataFlowRow(
items = listOf(series.year, "${series.seasonCount} Seasons")
)
}
@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()
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
.then(
if (selectedItemFocusRequester != null) {
Modifier.focusRestorer(selectedItemFocusRequester)
} else {
Modifier.focusRestorer()
}
)
.focusGroup()
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
TvSeasonTab(
name = season.name,
isSelected = index == selectedSeasonIndex,
onFocused = { onSelect(season) },
onClick = { onSelect(season) },
modifier = Modifier
.then(
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
Modifier.focusRequester(selectedItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
)
)
}
}
}
@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 isFocused = remember { mutableStateOf(false) }
Box(
modifier = modifier
.onFocusChanged { state ->
if (state.isFocused) {
onFocused()
isFocused.value = true
} else {
isFocused.value = false
}
}
.clickable(onClick = onClick)
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
) {
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)
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TvEpisodeCarousel(
episodes: List<Episode>,
onPlayEpisode: (Episode) -> Unit,
modifier: Modifier = Modifier,
focusedEpisodeId: UUID? = null,
requestFocus: Boolean = false,
onFocusRequested: () -> Unit = {},
upFocusRequester: FocusRequester? = null
) {
val listState = rememberLazyListState()
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, targetEpisodeId) {
if (episodes.none { it.id == rowFocusedEpisodeId }) {
rowFocusedEpisodeId = targetEpisodeId
}
if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetEpisodeIndex)
} else {
listState.scrollToItem(0)
}
}
LaunchedEffect(requestFocus, targetEpisodeId) {
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
rowFocusedEpisodeId = targetEpisodeId
if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetEpisodeIndex)
} else {
listState.scrollToItem(0)
}
withFrameNanos { }
rowScopedFocusRequester.requestFocus()
requestedFocusEpisodeId = targetEpisodeId
onFocusRequested()
}
}
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
state = listState,
modifier = modifier
.focusRestorer(rowScopedFocusRequester)
.focusGroup(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(episodes, key = { episode -> episode.id }) { episode ->
TvEpisodeCard(
episode = episode,
onPlayEpisode = { onPlayEpisode(episode) },
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
}
)
)
}
}
}
}
@Composable
internal fun TvSeriesHeroSection(
series: Series,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
Column(
modifier = modifier
.padding(top = 24.dp)
.fillMaxWidth()
) {
Text(
text = series.name,
color = scheme.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
TvSeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Overview",
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = series.synopsis,
color = mutedStrong,
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.4f)
)
}
}
@Composable
private fun TvEpisodeCard(
episode: Episode,
onPlayEpisode: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
Column(
modifier = modifier
.width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused()
}
.clickable { onPlayEpisode() },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surface)
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant,
shape = RoundedCornerShape(12.dp)
)
) {
PurefinAsyncImage(
model = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.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),
modifier = Modifier
.align(Alignment.BottomStart)
)
} else {
WatchStateBadge(
watched = episode.watched,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
}
Column(
) {
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Episode ${episode.index}",
color = mutedStrong,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
// MediaCastRow(
// cast = cast,
// modifier = modifier,
// cardWidth = 84.dp,
// nameSize = 11.sp,
// roleSize = 10.sp
// )
}
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
if (isFocused) {
this.border(
width = 2.dp,
color = borderColor,
shape = RoundedCornerShape(12.dp)
)
} else {
this
}

View File

@@ -0,0 +1,382 @@
package hu.bbara.purefin.ui.screen.settings
import android.widget.Toast
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.feature.settings.SettingsViewModel
import hu.bbara.purefin.core.settings.BooleanSetting
import hu.bbara.purefin.core.settings.DropdownSetting
import hu.bbara.purefin.core.settings.RangeSetting
import hu.bbara.purefin.core.settings.ReadOnlySetting
import hu.bbara.purefin.core.settings.SettingOption
import hu.bbara.purefin.core.settings.StringSetting
import hu.bbara.purefin.core.settings.VoidSetting
import java.util.Locale
@Composable
fun TvSettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
onBack: () -> Unit = viewModel::onBack,
modifier: Modifier = Modifier
) {
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(viewModel, context) {
viewModel.snackbarMessages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
TvSettingsTopBar(onBack = onBack)
}
) { innerPadding ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
item {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
)
}
settingGroups.forEach { group ->
group.title?.let { title ->
item {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp)
)
}
}
group.options.forEach { option ->
item(key = option.key) {
TvSettingOptionItem(
option = option,
viewModel = viewModel
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun TvSettingOptionItem(
option: SettingOption<*>,
viewModel: SettingsViewModel
) {
when (option) {
is RangeSetting -> {
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
value?.let {
TvNumberSettingItem(
title = option.title,
value = it,
valueRange = option.valueRange,
onValueChange = { value -> viewModel.set(option, value) }
)
}
}
is BooleanSetting -> {
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
value?.let {
TvBooleanSettingItem(
title = option.title,
value = it,
onValueChange = { value -> viewModel.set(option, value) }
)
}
}
is StringSetting -> {
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
value?.let {
TvStringSettingItem(
title = option.title,
value = it,
onValueChange = { value -> viewModel.set(option, value) }
)
}
}
is ReadOnlySetting -> {
TvReadOnlySettingItem(
title = option.title,
value = option.value
)
}
is VoidSetting -> {
TvVoidSettingItem(
title = option.title,
onClick = { viewModel.onClick(option) }
)
}
is DropdownSetting<*> -> {
TvDropdownSettingOptionItem(
option = option,
viewModel = viewModel
)
}
}
}
@Composable
private fun <T> TvDropdownSettingOptionItem(
option: DropdownSetting<T>,
viewModel: SettingsViewModel
) {
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
value?.let {
TvDropdownSettingItem(
title = option.title,
value = it,
options = option.options,
onValueChange = { value -> viewModel.set(option, value) }
)
}
}
@Composable
private fun TvSettingsTopBar(
onBack: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back"
)
}
}
}
@Composable
private fun TvReadOnlySettingItem(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun TvVoidSettingItem(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 28.dp, vertical = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
private fun <T> TvDropdownSettingItem(
title: String,
value: T,
options: List<T>,
onValueChange: (T) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }
.padding(horizontal = 28.dp, vertical = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = value.toString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.toString()) },
onClick = {
expanded = false
onValueChange(option)
}
)
}
}
}
}
@Composable
private fun TvNumberSettingItem(
title: String,
value: Double,
valueRange: ClosedFloatingPointRange<Double>,
onValueChange: (Double) -> Unit,
modifier: Modifier = Modifier
) {
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = String.format(Locale.US, "%.1f", sliderValue),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = sliderValue,
onValueChange = { sliderValue = it },
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun TvBooleanSettingItem(
title: String,
value: Boolean,
onValueChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxWidth()
.clickable { onValueChange(!value) }
.padding(horizontal = 28.dp, vertical = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Switch(
checked = value,
onCheckedChange = onValueChange
)
}
}
@Composable
private fun TvStringSettingItem(
title: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(title) },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words
),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 16.dp)
)
}

View File

@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/purefin_logo_background"/>
<background android:drawable="@color/purefin_logo_background"/>
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/purefin_logo_background"/>
<background android:drawable="@color/purefin_logo_background"/>
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purefin_logo_background">#201337</color>
</resources>

View File

@@ -1,37 +0,0 @@
package hu.bbara.purefin.app.content.episode
import hu.bbara.purefin.core.data.navigation.Route
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EpisodeTopBarShortcutTest {
@Test
fun `home route exposes series shortcut`() {
var clicked = false
val shortcut = episodeTopBarShortcut(Route.Home) {
clicked = true
}
assertNotNull(shortcut)
assertEquals("Series", shortcut?.label)
shortcut?.onClick?.invoke()
assertTrue(clicked)
}
@Test
fun `non home route hides series shortcut`() {
val shortcut = episodeTopBarShortcut(
previousRoute = Route.PlayerRoute(mediaId = "episode-1"),
onSeriesClick = {}
)
assertNull(shortcut)
}
}

View File

@@ -1,39 +1,34 @@
package hu.bbara.purefin.tv.home.ui
package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.core.image.ArtworkKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.UUID
class TvFocusedItemHeroTest {
@Test
fun movieItem_mapsProgressMetadataAndBackdrop() {
fun movieItem_mapsMetadataAndBackdrop() {
val movie = sampleMovie(progress = 42.0)
val item = ContinueWatchingItem(
type = BaseItemKind.MOVIE,
type = MediaKind.MOVIE,
movie = movie
)
val hero = item.toTvFocusedHeroModel()
assertEquals("Continue watching", hero.eyebrowText)
assertEquals("Blade Runner 2049", hero.title)
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
assertEquals(0.42f, hero.progressFraction!!, 0.0001f)
assertEquals("42%", hero.progressLabel)
assertNull(hero.watchedText)
assertEquals(
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP),
ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.BACKDROP),
hero.backdropImageUrl
)
}
@@ -50,7 +45,7 @@ class TvFocusedItemHeroTest {
}
@Test
fun episodeItem_prefersWatchedStateOverProgress() {
fun episodeItem_mapsEpisodeMetadata() {
val item = NextUpItem(
episode = sampleEpisode(
progress = 96.0,
@@ -60,29 +55,21 @@ class TvFocusedItemHeroTest {
val hero = item.toTvFocusedHeroModel()
assertEquals("Next up", hero.eyebrowText)
assertEquals("The Very Pulse of the Machine", hero.title)
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
assertEquals("Watched", hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
}
@Test
fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() {
fun seriesItem_usesSeasonAndUnwatchedMetadata() {
val item = PosterItem(
type = BaseItemKind.SERIES,
type = MediaKind.SERIES,
series = sampleSeries()
)
val hero = item.toTvFocusedHeroModel()
assertEquals("Series", hero.eyebrowText)
assertEquals("Love, Death & Robots", hero.title)
assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText)
assertNull(hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
}
private fun sampleMovie(progress: Double? = 42.0): Movie {
@@ -108,7 +95,9 @@ class TvFocusedItemHeroTest {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seriesName = "Love, Death & Robots",
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seasonIndex = 3,
index = 3,
title = "The Very Pulse of the Machine",
synopsis = "An astronaut faces a strange world alone.",

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