Compare commits

...

237 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
303 changed files with 95100 additions and 5383 deletions

1
.gitignore vendored
View File

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

View File

@@ -72,7 +72,6 @@ When asked to prepare a commit:
3. Inspect the relevant files and nearby code. 3. Inspect the relevant files and nearby code.
4. Choose the simplest solution aligned with Googles standards. 4. Choose the simplest solution aligned with Googles standards.
5. Check if the code can be simplified: 5. Check if the code can be simplified:
* If yes, simplify it. * If yes, simplify it.
* Do not reduce readability. * Do not reduce readability.
6. Summarize the result, including anything not verified. 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 devices: Select your device/emulator and click Run
- For Android TV: Select an Android TV emulator or device - 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 ## Usage

View File

@@ -13,13 +13,13 @@ plugins {
android { android {
namespace = "hu.bbara.purefin.tv" namespace = "hu.bbara.purefin.tv"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "hu.bbara.purefin.tv" applicationId = "hu.bbara.purefin.tv"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 2000001 versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
versionName = "0.1" versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -53,11 +53,15 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":data"))
implementation(project(":core")) implementation(project(":core"))
implementation(project(":core-model")) implementation(project(":core-model"))
implementation(project(":core-ui")) implementation(project(":core-ui"))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

@@ -19,10 +19,10 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.ui.model.EpisodeUiModel import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.TvHomeScreen import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals

View File

@@ -24,8 +24,8 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test

View File

@@ -5,7 +5,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule

View File

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

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@@ -23,7 +25,11 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/purefin_logo_round" android:roundIcon="@mipmap/purefin_logo_round"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin"> 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 <activity
android:name="hu.bbara.purefin.tv.TvActivity" android:name="hu.bbara.purefin.tv.TvActivity"
android:exported="true" android:exported="true"
@@ -34,6 +40,9 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -6,13 +6,14 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.tvEpisodeSection import hu.bbara.purefin.navigation.tvEpisodeSection
import hu.bbara.purefin.navigation.tvHomeSection import hu.bbara.purefin.navigation.tvHomeSection
import hu.bbara.purefin.navigation.tvLibrarySection import hu.bbara.purefin.navigation.tvLibrarySection
import hu.bbara.purefin.navigation.tvLoginSection import hu.bbara.purefin.navigation.tvLoginSection
import hu.bbara.purefin.navigation.tvMovieSection import hu.bbara.purefin.navigation.tvMovieSection
import hu.bbara.purefin.navigation.tvPlayerSection import hu.bbara.purefin.navigation.tvPlayerSection
import hu.bbara.purefin.navigation.tvSettingsSection
import hu.bbara.purefin.navigation.tvSeriesSection import hu.bbara.purefin.navigation.tvSeriesSection
@Module @Module
@@ -60,4 +61,10 @@ object TvNavigationModule {
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = { fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvLibrarySection() tvLibrarySection()
} }
@IntoSet
@Provides
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvSettingsSection()
}
} }

View File

@@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.PlaybackProfileFamily import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View File

@@ -3,6 +3,8 @@ package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> = val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") } staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -2,13 +2,16 @@ package hu.bbara.purefin.navigation
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.browse.home.AppViewModel 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.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.TvLoginScreen
import hu.bbara.purefin.ui.screen.TvAppScreen import hu.bbara.purefin.ui.screen.TvAppScreen
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
fun EntryProviderScope<Route>.tvHomeSection() { fun EntryProviderScope<Route>.tvHomeSection() {
entry<Route.Home> { entry<Route.Home> {
@@ -18,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
fun EntryProviderScope<Route>.tvLoginSection() { fun EntryProviderScope<Route>.tvLoginSection() {
entry<Route.LoginRoute> { entry<Route.LoginRoute> {
LoginScreen() TvLoginScreen()
} }
} }
@@ -37,7 +40,8 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
fun EntryProviderScope<Route>.tvEpisodeSection() { fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route -> entry<Route.EpisodeRoute> { route ->
TvSeriesScreen( TvSeriesScreen(
series = SeriesDto(id = route.item.seriesId), series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
focusedSeasonId = route.item.seasonId,
focusedEpisodeId = route.item.id focusedEpisodeId = route.item.id
) )
} }
@@ -62,3 +66,9 @@ fun EntryProviderScope<Route>.tvLibrarySection() {
) )
} }
} }
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.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -11,15 +13,20 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
@@ -36,15 +43,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.data.SessionBootstrapper import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.navigation.NavigationCommand import hu.bbara.purefin.core.feature.update.AppUpdateInfo
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.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.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.TvLoginScreen
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark import hu.bbara.purefin.ui.theme.backgroundDark
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
fun MainApp( fun MainApp(
userSessionRepository: UserSessionRepository, userSessionRepository: UserSessionRepository,
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>, entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
navigationManager: NavigationManager navigationManager: NavigationManager,
updateViewModel: AppUpdateViewModel = hiltViewModel()
) { ) {
var sessionLoaded by remember { mutableStateOf(false) } 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) { LaunchedEffect(Unit) {
userSessionRepository.isLoggedIn.collect { userSessionRepository.isLoggedIn.collect {
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
} }
} }
if (!sessionLoaded) { LaunchedEffect(updateViewModel) {
PurefinWaitingScreen(modifier = Modifier.fillMaxSize()) updateViewModel.checkForUpdatesOnAppOpen()
return
} }
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") @Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route> val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
val appEntryProvider = entryProvider { val appEntryProvider = entryProvider {
@@ -161,7 +185,13 @@ class TvActivity : ComponentActivity() {
LaunchedEffect(navigationManager, backStack) { LaunchedEffect(navigationManager, backStack) {
navigationManager.commands.collect { command -> navigationManager.commands.collect { command ->
when (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.Navigate -> backStack.add(command.route)
is NavigationCommand.ReplaceAll -> { is NavigationCommand.ReplaceAll -> {
backStack.clear() backStack.clear()
@@ -171,13 +201,23 @@ class TvActivity : ComponentActivity() {
} }
} }
BackHandler(enabled = backStack.size == 1) {
showExitDialog = true
}
CompositionLocalProvider( CompositionLocalProvider(
LocalNavigationManager provides navigationManager, LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList() LocalNavigationBackStack provides backStack.toList()
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { navigationManager.pop() }, onBack = {
if (backStack.size > 1) {
navigationManager.pop()
} else {
showExitDialog = true
}
},
modifier = Modifier.fillMaxSize().background(backgroundDark), modifier = Modifier.fillMaxSize().background(backgroundDark),
transitionSpec = { transitionSpec = {
fadeIn( fadeIn(
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
) )
} }
} else { } 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

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

View File

@@ -29,12 +29,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -134,10 +135,17 @@ fun PosterCardContent(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(indicatorPadding), .padding(indicatorPadding),
watched = model.watched, watched = model.watched,
started = (model.progress ?: 0f) > 0f
) )
} }
else -> Unit is SeriesUiModel -> {
UnwatchedEpisodeBadge(
unwatchedCount = model.unwatchedEpisodeCount,
size = 26,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
)
}
} }
} }
Column( Column(

View File

@@ -27,8 +27,8 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight 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.ui.unit.sp
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
internal val MediaDetailHorizontalPadding = 48.dp internal val MediaDetailHorizontalPadding = 48.dp
@@ -89,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = bodyHeight) .heightIn(min = bodyHeight)
) { ) {
Box(modifier = Modifier.matchParentSize()) { Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
.align(Alignment.TopEnd)
) {
PurefinAsyncImage( PurefinAsyncImage(
model = backgroundImageUrl, model = backgroundImageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxHeight()
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
.align(Alignment.TopEnd),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.12f)
)
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.16f),
1.0f to scheme.background
)
)
)
)
} }
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.12f)
)
)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.16f),
1.0f to scheme.background
)
)
)
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

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

View File

@@ -1,13 +1,15 @@
package hu.bbara.purefin.ui.screen package hu.bbara.purefin.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.Tv
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -18,25 +20,26 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel 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.model.LibraryKind
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.ui.screen.home.TvHomeScreen 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.TvDrawerDestinationItem
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
@Composable @Composable
fun TvAppScreen( fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(), viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val libraries by viewModel.libraries.collectAsState() val libraries by viewModel.libraries.collectAsStateWithLifecycle()
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState() val latestLibraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -62,7 +65,11 @@ fun TvAppScreen(
LibraryKind.SERIES -> Icons.Outlined.Tv LibraryKind.SERIES -> Icons.Outlined.Tv
} }
) )
} } + TvDrawerDestinationItem(
destination = Route.SettingsRoute,
label = "Settings",
icon = Icons.Outlined.Settings
)
} }
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
@@ -88,16 +95,29 @@ fun TvAppScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
entry<Route.SettingsRoute> {
TvSettingsScreen(
onBack = { backStack.removeLastOrNull() },
modifier = Modifier.fillMaxSize()
)
}
}
BackHandler(enabled = backStack.size == 1) {
navigationManager.pop()
} }
TvNavigationDrawer( TvNavigationDrawer(
destinations = destinations, destinations = destinations,
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = { destination -> onDestinationSelected = { destination ->
if (selectedDestination == Route.Home) {
viewModel.onRefresh()
}
if (selectedDestination != destination) { if (selectedDestination != destination) {
backStack.clear() backStack.clear()
backStack.add(Route.Home) backStack.add(Route.Home)
if (destination is Route.LibraryRoute) { if (destination != Route.Home) {
backStack.add(destination) backStack.add(destination)
} }
} }

View File

@@ -6,20 +6,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
@@ -37,15 +37,10 @@ fun TvEpisodeScreen(
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(episode)
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
} }
val episode = viewModel.episode.collectAsState() val episode = viewModel.episode.collectAsStateWithLifecycle()
val seriesTitle = viewModel.seriesTitle.collectAsState()
val selectedEpisode = episode.value val selectedEpisode = episode.value
if (selectedEpisode == null) { if (selectedEpisode == null) {
@@ -55,7 +50,7 @@ fun TvEpisodeScreen(
TvEpisodeScreenContent( TvEpisodeScreenContent(
episode = selectedEpisode, episode = selectedEpisode,
seriesTitle = seriesTitle.value, seriesTitle = selectedEpisode.seriesName,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
{ {
navigationManager.navigate( navigationManager.navigate(

View File

@@ -1,6 +1,6 @@
package hu.bbara.purefin.ui.screen.home package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -10,12 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -31,31 +31,33 @@ fun TvHomeScreen(
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) } val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxSize(),
.fillMaxSize() color = scheme.background
.background(scheme.background)
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
Column( TvHomeHeroBackdrop(
modifier = Modifier.fillMaxSize() backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value,
height = 220.dp
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) )
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

@@ -2,118 +2,136 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import kotlin.math.roundToInt
internal const val TvHomeHeroTitleTag = "tv-home-hero-title" internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label" internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180 private const val TvHomeHeroAnimationMillis = 180
// 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 @Composable
internal fun TvFocusedItemHero( internal fun TvHomeHeroBackdrop(
item: MediaUiModel, backdropImageUrl: String?,
height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box( Crossfade(
targetState = backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-backdrop",
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .fillMaxHeight(heightFraction)
.background(scheme.background) .drawWithContent {
) { drawContent()
Crossfade( // Darken the left side so the text block stays readable over any backdrop.
targetState = item.backdropImageUrl, drawRect(
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( Brush.horizontalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background, 0.0f to scheme.background.copy(alpha = 0.86f),
0.28f to scheme.background.copy(alpha = 0.88f), 0.34f to scheme.background.copy(alpha = 0.62f),
0.62f to scheme.background.copy(alpha = 0.42f), 0.66f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.06f) 1.0f to scheme.background.copy(alpha = 0.04f)
) )
) )
) )
) // Strong bottom fade so the title and metadata are legible.
Box( drawRect(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient( Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f), 0.0f to scheme.background.copy(alpha = 0f),
0.56f to scheme.background.copy(alpha = 0.1f), 0.40f to scheme.background.copy(alpha = 0.10f),
0.72f to scheme.background.copy(alpha = 0.58f),
1.0f to scheme.background 1.0f to scheme.background
) )
) )
) )
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
) )
Crossfade( }
targetState = item, }
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content" @Composable
) { hero -> 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( Column(
verticalArrangement = Arrangement.Bottom, verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier.widthIn(max = 720.dp)
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 18.dp)
) { ) {
Column( Text(
verticalArrangement = Arrangement.spacedBy(8.dp), text = hero.primaryText,
modifier = Modifier.widthIn(max = 720.dp) color = scheme.onBackground,
) { fontSize = 40.sp,
Text( fontWeight = FontWeight.Bold,
text = hero.primaryText, lineHeight = 44.sp,
color = scheme.onBackground, maxLines = 2,
fontSize = 34.sp, overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold, modifier = Modifier.testTag(TvHomeHeroTitleTag)
lineHeight = 38.sp, )
maxLines = 1, TvHomeHeroMetadataRow(item = hero)
overflow = TextOverflow.Ellipsis, if (hero.description.isNotBlank()) {
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
Text( Text(
text = hero.description, text = hero.description,
color = scheme.onSurfaceVariant, color = scheme.onSurfaceVariant,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
@@ -121,3 +139,27 @@ internal fun TvFocusedItemHero(
} }
} }
} }
@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

@@ -2,7 +2,6 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -24,8 +22,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import java.util.UUID import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -42,7 +40,6 @@ fun TvHomeContent(
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp), contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() } val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val hasContinueWatching = continueWatching.isNotEmpty() val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty() val hasNextUp = nextUp.isNotEmpty()
@@ -65,7 +62,6 @@ fun TvHomeContent(
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(scheme.background)
.focusRequester(initialFocusRequester), .focusRequester(initialFocusRequester),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = contentPadding contentPadding = contentPadding

View File

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

View File

@@ -51,7 +51,7 @@ import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCardContent import hu.bbara.purefin.ui.common.card.PosterCardContent
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt import kotlin.math.roundToInt

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,21 +15,30 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow 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 androidx.tv.material3.DrawerState
import androidx.tv.material3.DrawerValue import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.darkColorScheme import androidx.tv.material3.darkColorScheme
import hu.bbara.purefin.navigation.Route import androidx.tv.material3.rememberDrawerState
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.tv.R import hu.bbara.purefin.tv.R
import androidx.tv.material3.MaterialTheme as TvMaterialTheme import androidx.tv.material3.MaterialTheme as TvMaterialTheme
@@ -44,11 +54,14 @@ fun TvNavigationDrawer(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
ProvideTvDrawerTheme { ProvideTvDrawerTheme {
val drawerState = rememberDrawerState(DrawerValue.Closed)
NavigationDrawer( NavigationDrawer(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
drawerContent = { drawerValue -> drawerState = drawerState,
drawerContent = {
TvNavigationDrawerRail( TvNavigationDrawerRail(
drawerValue = drawerValue, drawerState = drawerState,
destinations = destinations, destinations = destinations,
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected onDestinationSelected = onDestinationSelected
@@ -117,6 +130,8 @@ fun TvDrawerHeader(
AnimatedVisibility(visible = expanded) { AnimatedVisibility(visible = expanded) {
Text( Text(
text = "Purefin", text = "Purefin",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
modifier = Modifier.testTag(TvDrawerTitleTag) modifier = Modifier.testTag(TvDrawerTitleTag)
) )
} }
@@ -125,20 +140,25 @@ fun TvDrawerHeader(
@Composable @Composable
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail( private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
drawerValue: DrawerValue, drawerState: DrawerState,
destinations: List<TvDrawerDestinationItem>, destinations: List<TvDrawerDestinationItem>,
selectedDestination: Route, selectedDestination: Route,
onDestinationSelected: (Route) -> Unit, onDestinationSelected: (Route) -> Unit,
) { ) {
val expanded = drawerValue == DrawerValue.Open val expanded = drawerState.currentValue == DrawerValue.Open
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var hasDrawerFocus by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.background(scheme.surface.copy(alpha = 0.96f)) .background(scheme.surface.copy(alpha = 0.96f))
.padding(horizontal = 12.dp, vertical = 24.dp) .padding(horizontal = 12.dp, vertical = 24.dp)
.selectableGroup(), .selectableGroup()
.focusGroup()
.onFocusChanged { state ->
hasDrawerFocus = state.isFocused
},
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
TvDrawerHeader( TvDrawerHeader(
@@ -149,10 +169,16 @@ private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
val isSelected = destination.destination == selectedDestination val isSelected = destination.destination == selectedDestination
NavigationDrawerItem( NavigationDrawerItem(
selected = isSelected, selected = isSelected,
onClick = { onDestinationSelected(destination.destination) }, onClick = {
onDestinationSelected(destination.destination)
if (isSelected) {
drawerState.setValue(DrawerValue.Closed)
}
},
modifier = Modifier modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index") .testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected }, .semantics { selected = isSelected }
.focusProperties { canFocus = hasDrawerFocus || isSelected },
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = destination.icon, imageVector = destination.icon,

View File

@@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-" internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"

View File

@@ -15,14 +15,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.browse.library.LibraryViewModel import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
fun TvLibraryScreen( fun TvLibraryScreen(
@@ -35,7 +35,7 @@ fun TvLibraryScreen(
viewModel.selectLibrary(libraryId = library.id) viewModel.selectLibrary(libraryId = library.id)
} }
val libraryItems = viewModel.contents.collectAsState() val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,

View File

@@ -1,68 +0,0 @@
package hu.bbara.purefin.ui.screen.login
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.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
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()
val state = remember(serverUrl, username, password, errorMessage) {
LoginContentState(
serverUrl = serverUrl,
username = username,
password = password,
errorMessage = errorMessage
)
}
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
}
LoginContent(
state = state,
callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier
)
}

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

@@ -6,15 +6,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
@@ -28,11 +28,11 @@ fun TvMovieScreen(
viewModel: MovieScreenViewModel = hiltViewModel(), viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LaunchedEffect(movie.id) { LaunchedEffect(movie) {
viewModel.selectMovie(movie.id) viewModel.selectMovie(movie)
} }
val movieItem = viewModel.movie.collectAsState() val movieItem = viewModel.movie.collectAsStateWithLifecycle()
if (movieItem.value != null) { if (movieItem.value != null) {
TvMovieScreenContent( TvMovieScreenContent(

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity import android.app.Activity
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -12,23 +11,29 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,31 +42,45 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key 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.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.player.viewmodel.PlayerViewModel 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.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.TvPlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard 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.TvTrackPanelType
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay 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 TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback" internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@@ -76,23 +95,43 @@ fun TvPlayerScreen(
viewModel.loadMedia(mediaId) viewModel.loadMedia(mediaId)
} }
val uiState by viewModel.uiState.collectAsState() val scope = rememberCoroutineScope()
val controlsVisible by viewModel.controlsVisible.collectAsState() 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 isPlaylistExpanded by remember { mutableStateOf(false) }
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) } var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) } var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
var stopFeedbackVisible by remember { mutableStateOf(false) }
var stopFeedbackRequestId by remember { mutableStateOf(0) } // This is a hack for timed visibility.
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val rootFocusRequester = remember { FocusRequester() }
val controlsFocusRequester = remember { FocusRequester() } val backgroundFocusRequester = remember { FocusRequester() }
// Main section focus requesters
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
val qualityButtonFocusRequester = remember { FocusRequester() } val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() }
val skipButtonFocusRequester = 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) { LaunchedEffect(uiState.isPlaying) {
val activity = context as? Activity val activity = context as? Activity
if (uiState.isPlaying) { if (uiState.isPlaying) {
@@ -101,102 +140,85 @@ fun TvPlayerScreen(
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} }
} }
DisposableEffect(Unit) {
onDispose { fun showControls() {
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) controlsVisible = true
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false) }
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false) 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(isPlaylistExpanded) { LaunchedEffect(controlsVisible) {
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded) if (controlsVisible) {
} controlsFocusRequester.requestFocus()
} else {
LaunchedEffect(trackPanelType) { rootFocusRequester.requestFocus()
viewModel.setControlsAutoHideBlocked(
ControlsAutoHideBlocker.TRACK_PANEL,
trackPanelType != null
)
}
val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) {
isPlaylistExpanded = true
} }
} }
val collapsePlaylistToControls: () -> Unit = { LifecycleEventEffect(Lifecycle.Event.ON_START) {
if (isPlaylistExpanded) { hideControlsWithTimeout()
isPlaylistExpanded = false
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
}
} }
val showTvControls: () -> Unit = {
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) fun expandPlaylist() {
// TODO: focus management for playlist expansion
isPlaylistExpanded = true
} }
val togglePlayPauseAndShowControls: () -> Unit = { fun closePlaylist() {
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS) // TODO focus
} isPlaylistExpanded = false
val pausePlaybackWithoutShowingControls: () -> Unit = {
viewModel.pausePlayback()
stopFeedbackVisible = true
stopFeedbackRequestId += 1
}
val resumePlaybackWithoutShowingControls: () -> Unit = {
viewModel.resumePlayback()
stopFeedbackVisible = false
}
val seekAndShowControls: (Long) -> Unit = { positionMs ->
viewModel.seekTo(positionMs)
showTvControls()
}
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
viewModel.seekBy(deltaMs)
showTvControls()
}
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
viewModel.seekBy(deltaMs)
stopFeedbackVisible = false
}
val seekToLiveEdgeAndShowControls: () -> Unit = {
viewModel.seekToLiveEdge()
showTvControls()
}
val skipSegmentAndShowControls: () -> Unit = {
viewModel.skipActiveSegment()
showTvControls()
}
val nextAndShowControls: () -> Unit = {
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
}
val previousAndShowControls: () -> Unit = {
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
} }
val closeTrackPanel: () -> Unit = { val closeTrackPanel: () -> Unit = {
trackPanelType?.let { panelType -> trackPanelType?.let { panelType ->
pendingTrackButtonFocus = panelType pendingTrackButtonFocus = panelType
trackPanelType = null trackPanelType = null
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS) }
}
fun handleBack() {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> closePlaylist()
controlsVisible -> hideControls()
else -> onBack()
} }
} }
LaunchedEffect(
controlsVisible, val showSkipIntroButton = !controlsVisible
controlsAutoHideBlocked, && uiState.activeSkippableSegmentEndMs != null
uiState.activeSkippableSegmentEndMs && uiState.activeSkippableSegmentType == SegmentType.INTRO
) { && !uiState.isEnded
if (controlsAutoHideBlocked) return@LaunchedEffect val showNextEpisodeOverlay = !controlsVisible
if (controlsVisible) { && uiState.nextEpisode != null
controlsFocusRequester.requestFocus() && uiState.durationMs > 0L
} else { && ((uiState.durationMs - uiState.positionMs) <= 30_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
if (uiState.activeSkippableSegmentEndMs != null) { && !uiState.isEnded
skipButtonFocusRequester.requestFocus() LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
return@LaunchedEffect rootFocusRequester = when {
showSkipIntroButton -> {
skipButtonFocusRequester
} }
focusManager.clearFocus() showNextEpisodeOverlay -> {
nextEpisodeFocusRequester
}
else -> {
backgroundFocusRequester
}
}
if (!controlsVisible) {
rootFocusRequester.requestFocus()
} }
} }
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) { LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
if (trackPanelType != null) return@LaunchedEffect if (trackPanelType != null) return@LaunchedEffect
@@ -208,59 +230,44 @@ fun TvPlayerScreen(
pendingTrackButtonFocus = null pendingTrackButtonFocus = null
} }
LaunchedEffect(stopFeedbackRequestId) {
if (stopFeedbackRequestId == 0) return@LaunchedEffect
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
stopFeedbackVisible = false
}
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
stopFeedbackVisible = false
}
}
val playerControlsVisible =
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction = val subtitleBottomPaddingFraction =
if (playerControlsVisible) { if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else { } else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
} }
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
isPlaylistExpanded -> collapsePlaylistToControls()
controlsVisible -> viewModel.toggleControlsVisibility()
else -> onBack()
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(rootFocusRequester) .focusRequester(rootFocusRequester)
.onPreviewKeyEvent { event -> .onKeyEvent { event ->
handleTvPlayerRootKeyEvent( if (event.key == Key.Back) {
event = event, if (event.type == KeyEventType.KeyDown) {
isPlaying = uiState.isPlaying, handleBack()
controlsVisible = controlsVisible, }
isPlaylistExpanded = isPlaylistExpanded, true
trackPanelType = trackPanelType, } else {
onCloseTrackPanel = closeTrackPanel, val handled = handleTvPlayerRootKeyEvent(
onCollapsePlaylist = collapsePlaylistToControls, event = event,
onHideControls = { viewModel.toggleControlsVisibility() }, controlsVisible = controlsVisible,
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls, popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls, onShowControls = ::showControls,
onSeekRelative = seekByWithoutShowingControls, onTogglePlayback = {
onShowControls = showTvControls, resumeStopFeedbackCounter++
onSkipSegment = skipSegmentAndShowControls, viewModel.togglePlayPause()
hasSkippableSegment = uiState.activeSkippableSegmentEndMs != null, },
onTogglePlayPause = togglePlayPauseAndShowControls onSeekRelative = {
) hiddenSeekCounter++
viewModel.seekBy(it)
},
)
if (event.type == KeyEventType.KeyDown) {
hideControlsWithTimeout()
}
handled
}
} }
.focusable() .focusable()
) { ) {
@@ -271,6 +278,8 @@ fun TvPlayerScreen(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction) subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
isFocusable = false
isFocusableInTouchMode = false
} }
}, },
update = { update = {
@@ -283,7 +292,7 @@ fun TvPlayerScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = playerControlsVisible, visible = controlsVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -295,21 +304,25 @@ fun TvPlayerScreen(
qualityButtonFocusRequester = qualityButtonFocusRequester, qualityButtonFocusRequester = qualityButtonFocusRequester,
audioButtonFocusRequester = audioButtonFocusRequester, audioButtonFocusRequester = audioButtonFocusRequester,
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester, subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
onPlayPause = togglePlayPauseAndShowControls, onPlayPause = { viewModel.togglePlayPause() },
onSeek = seekAndShowControls, onSeek = { positionMs ->
onSeekRelative = seekByAndShowControls, viewModel.seekTo(positionMs)
onSeekLiveEdge = seekToLiveEdgeAndShowControls, },
onSkipSegment = skipSegmentAndShowControls, onSeekRelative = { deltaMs ->
onNext = nextAndShowControls, viewModel.seekBy(deltaMs)
onPrevious = previousAndShowControls, },
onSkipSegment = {
viewModel.skipActiveSegment()
},
onNext = { viewModel.next() },
onPrevious = { viewModel.previous() },
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES }, onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY }, onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
onExpandPlaylist = expandPlaylist, onExpandPlaylist = ::expandPlaylist,
onCollapsePlaylist = collapsePlaylistToControls, onCollapsePlaylist = ::closePlaylist,
onSelectQueueItem = { id -> onSelectQueueItem = { id ->
viewModel.playQueueItem(id) viewModel.playQueueItem(id)
collapsePlaylistToControls()
}, },
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(), qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
audioButtonEnabled = uiState.audioTracks.isNotEmpty(), audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
@@ -318,42 +331,89 @@ fun TvPlayerScreen(
} }
AnimatedVisibility( AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null, visible = controlsVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
TvPlayerClock()
}
AnimatedVisibility(
visible = showSkipIntroButton,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp) .padding(
end = 24.dp,
bottom = 24.dp
)
) { ) {
TvIconButton( TvIconButton(
icon = Icons.Outlined.SkipNext, icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment", contentDescription = "Skip segment",
onClick = skipSegmentAndShowControls, onClick = { viewModel.skipActiveSegment() },
size = 64, size = 64,
label = "Skip", label = "Skip",
modifier = Modifier.focusRequester(skipButtonFocusRequester) 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( TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
uiState = uiState, uiState = uiState,
onRetry = { viewModel.retry() }, onRetry = { viewModel.retry() },
onNext = nextAndShowControls, onNext = { viewModel.next() },
onReplay = { onReplay = {
viewModel.seekTo(0L) viewModel.seekTo(0L)
togglePlayPauseAndShowControls() viewModel.resumePlayback()
}, },
onDismissError = { viewModel.clearError() } onDismissError = { viewModel.clearError() }
) )
AnimatedVisibility( ValueChangeTimedVisibility(
visible = stopFeedbackVisible, value = resumeStopFeedbackCounter,
enter = fadeIn(), hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) { ) {
TvPlayerHiddenStopFeedback() TvPlayerStopFeedback(stopped = !uiState.isPlaying)
} }
AnimatedVisibility( AnimatedVisibility(
@@ -377,45 +437,74 @@ fun TvPlayerScreen(
} }
} }
internal fun handleTvPlayerRootKeyEvent( @Composable
event: androidx.compose.ui.input.key.KeyEvent, private fun HiddenTvSeekTimeline(
isPlaying: Boolean, positionMs: Long,
controlsVisible: Boolean, durationMs: Long,
isPlaylistExpanded: Boolean, bufferedMs: Long,
trackPanelType: TvTrackPanelType?, chapterMarkers: List<TimedMarker>,
onCloseTrackPanel: () -> Unit, adMarkers: List<TimedMarker>,
onCollapsePlaylist: () -> Unit, modifier: Modifier = Modifier
onHideControls: () -> Unit, ) {
onPausePlaybackWithoutShowingControls: () -> Unit, val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
onResumePlaybackWithoutShowingControls: () -> Unit,
onSeekRelative: (Long) -> Unit,
onShowControls: () -> Unit,
onSkipSegment: () -> Unit = {},
hasSkippableSegment: Boolean = false,
onTogglePlayPause: () -> Unit
): Boolean {
if (event.type != KeyEventType.KeyDown) return false
if (event.key == Key.Back || event.key == Key.Escape) { Column(
return when { modifier = modifier
trackPanelType != null -> { .fillMaxWidth()
onCloseTrackPanel() .clip(RoundedCornerShape(16.dp))
true .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
)
}
}
isPlaylistExpanded -> { @Composable
onCollapsePlaylist() private fun TvPlayerClock(modifier: Modifier = Modifier) {
true var currentTime by remember { mutableStateOf(LocalTime.now()) }
} LaunchedEffect(Unit) {
while (true) {
controlsVisible -> { currentTime = LocalTime.now()
onHideControls() delay(1_000L)
true
}
else -> false
} }
} }
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) { if (!controlsVisible) {
return when (event.key) { return when (event.key) {
@@ -425,7 +514,7 @@ internal fun handleTvPlayerRootKeyEvent(
} }
Key.DirectionRight -> { Key.DirectionRight -> {
onSeekRelative(10_000) onSeekRelative(30_000)
true true
} }
@@ -435,14 +524,13 @@ internal fun handleTvPlayerRootKeyEvent(
} }
Key.DirectionCenter, Key.Enter -> { Key.DirectionCenter, Key.Enter -> {
if (hasSkippableSegment) { if (popupVisible) {
onSkipSegment() // Do nothing because the focused component is not he root, but an overlay
} else if (isPlaying) { false
onPausePlaybackWithoutShowingControls()
} else { } else {
onResumePlaybackWithoutShowingControls() onTogglePlayback()
true
} }
true
} }
else -> false else -> false
@@ -453,9 +541,13 @@ internal fun handleTvPlayerRootKeyEvent(
} }
@Composable @Composable
internal fun TvPlayerHiddenStopFeedback( internal fun TvPlayerStopFeedback(
stopped: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (!stopped) {
return
}
Box( Box(
modifier = modifier modifier = modifier
.testTag(TvPlayerHiddenStopFeedbackTag) .testTag(TvPlayerHiddenStopFeedbackTag)
@@ -466,7 +558,7 @@ internal fun TvPlayerHiddenStopFeedback(
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Pause, imageVector = Icons.Outlined.Pause,
contentDescription = "Pause playback", contentDescription = "Play/Pause playback",
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(72.dp) modifier = Modifier.size(72.dp)
) )

View File

@@ -54,6 +54,18 @@ internal fun TvIconButton(
targetValue = if (isFocused) scheme.primary else Color.Transparent, targetValue = if (isFocused) scheme.primary else Color.Transparent,
label = "border" 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 shape = RoundedCornerShape(50)
val iconSize = (size - 24).coerceAtLeast(0).dp val iconSize = (size - 24).coerceAtLeast(0).dp
@@ -69,10 +81,7 @@ internal fun TvIconButton(
shape = shape shape = shape
) )
.clip(shape) .clip(shape)
.background( .background(backgroundColor)
if (isFocused) scheme.primary.copy(alpha = 0.5f)
else scheme.background.copy(alpha = 0.65f)
)
.focusProperties { canFocus = enabled } .focusProperties { canFocus = enabled }
.semantics { .semantics {
if (!enabled) { if (!enabled) {
@@ -86,7 +95,7 @@ internal fun TvIconButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = scheme.onBackground, tint = contentColor,
modifier = buttonModifier modifier = buttonModifier
.size(size.dp) .size(size.dp)
.padding(12.dp) .padding(12.dp)
@@ -104,12 +113,12 @@ internal fun TvIconButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = scheme.onBackground, tint = contentColor,
modifier = Modifier.size(iconSize) modifier = Modifier.size(iconSize)
) )
Text( Text(
text = label, text = label,
color = scheme.onBackground, color = contentColor,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold 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

@@ -16,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Forward30
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Replay10
import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -46,7 +44,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state" internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button" internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
@@ -63,7 +61,6 @@ internal fun TvPlayerControlsOverlay(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit, onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
@@ -109,7 +106,6 @@ internal fun TvPlayerControlsOverlay(
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment, onSkipSegment = onSkipSegment,
onNext = onNext, onNext = onNext,
onPrevious = onPrevious, onPrevious = onPrevious,
@@ -172,7 +168,6 @@ private fun TvPlayerBottomSection(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit, onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
@@ -187,7 +182,6 @@ private fun TvPlayerBottomSection(
subtitlesButtonEnabled: Boolean, subtitlesButtonEnabled: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
val playlistFocusRequester = remember { FocusRequester() } val playlistFocusRequester = remember { FocusRequester() }
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) } var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
@@ -210,24 +204,11 @@ private fun TvPlayerBottomSection(
.testTag(TvPlayerPlaylistStateTag) .testTag(TvPlayerPlaylistStateTag)
.semantics { stateDescription = playlistExpandState } .semantics { stateDescription = playlistExpandState }
) { ) {
Row( TvPlayerTimeRow(
modifier = Modifier positionMs = uiState.positionMs,
.fillMaxWidth() durationMs = uiState.durationMs,
.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp)
horizontalArrangement = Arrangement.SpaceBetween, )
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
TvPlayerSeekBar( TvPlayerSeekBar(
positionMs = uiState.positionMs, positionMs = uiState.positionMs,
durationMs = uiState.durationMs, durationMs = uiState.durationMs,
@@ -261,13 +242,6 @@ private fun TvPlayerBottomSection(
size = 56, size = 56,
modifier = expandPlaylistModifier modifier = expandPlaylistModifier
) )
TvIconButton(
icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward 10 seconds",
onClick = { onSeekRelative(-10_000) },
size = 56,
modifier = expandPlaylistModifier
)
TvIconButton( TvIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play", contentDescription = if (uiState.isPlaying) "Pause" else "Play",
@@ -277,13 +251,6 @@ private fun TvPlayerBottomSection(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.testTag(TvPlayerPlayPauseButtonTag) .testTag(TvPlayerPlayPauseButtonTag)
) )
TvIconButton(
icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward 30 seconds",
onClick = { onSeekRelative(30_000) },
size = 56,
modifier = expandPlaylistModifier
)
TvIconButton( TvIconButton(
icon = Icons.Outlined.SkipNext, icon = Icons.Outlined.SkipNext,
contentDescription = "Next", contentDescription = "Next",
@@ -355,6 +322,32 @@ private fun TvPlayerBottomSection(
} }
} }
@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( private fun handleExpandPlaylistKey(
event: androidx.compose.ui.input.key.KeyEvent, event: androidx.compose.ui.input.key.KeyEvent,
onExpand: () -> Unit onExpand: () -> Unit

View File

@@ -4,7 +4,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable @Composable
internal fun TvPlayerLoadingErrorEndCard( internal fun TvPlayerLoadingErrorEndCard(

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.ui.screen.player.components 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -7,14 +9,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -25,11 +32,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged 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.graphics.painter.ColorPainter
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
@@ -42,8 +52,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.player.model.PlaylistElementUiModel import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row" internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item" internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
@@ -82,75 +92,56 @@ internal fun TvPlayerQueuePanel(
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
color = scheme.surface.copy(alpha = 0.94f) color = scheme.surface.copy(alpha = 0.94f)
) { ) {
Column( if (uiState.queue.isEmpty()) {
modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp), Text(
verticalArrangement = Arrangement.spacedBy(16.dp) text = "Add something to the queue to browse it here.",
) { color = scheme.onSurfaceVariant,
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { style = MaterialTheme.typography.bodyMedium
Text( )
text = "Playlist", } else {
color = scheme.onSurface, LazyRow(
style = MaterialTheme.typography.titleLarge, modifier = Modifier
fontWeight = FontWeight.Bold .fillMaxWidth()
) .testTag(TvPlayerPlaylistRowTag),
Text( state = listState,
text = queueCountLabel, horizontalArrangement = Arrangement.spacedBy(14.dp),
color = scheme.onSurfaceVariant, contentPadding = PaddingValues(16.dp)
style = MaterialTheme.typography.bodyMedium ) {
) itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
} val isEntryItem = index == entryIndex
TvQueueRowCard(
if (uiState.queue.isEmpty()) { item = item,
Text( isCurrent = item.isCurrent,
text = "Add something to the queue to browse it here.", isFirst = index == 0,
color = scheme.onSurfaceVariant, isLast = index == uiState.queue.lastIndex,
style = MaterialTheme.typography.bodyMedium onClick = { onSelect(item.id) },
) onReturnToControls = onReturnToControls,
} else { modifier = Modifier
LazyRow( .width(228.dp)
modifier = Modifier .then(
.fillMaxWidth() if (isEntryItem) {
.testTag(TvPlayerPlaylistRowTag), Modifier
state = listState, .focusRequester(firstItemFocusRequester)
horizontalArrangement = Arrangement.spacedBy(14.dp), .testTag(TvPlayerPlaylistCurrentItemTag)
contentPadding = PaddingValues(end = 4.dp) } else {
) { Modifier
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item -> }
val isEntryItem = index == entryIndex )
TvQueueRowCard( .then(
item = item, if (index == 0 && !isEntryItem) {
isCurrent = item.isCurrent, Modifier.testTag(TvPlayerPlaylistFirstItemTag)
isFirst = index == 0, } else {
isLast = index == uiState.queue.lastIndex, Modifier
onClick = { onSelect(item.id) }, }
onReturnToControls = onReturnToControls, )
modifier = Modifier .then(
.width(228.dp) if (index == uiState.queue.lastIndex && !isEntryItem) {
.then( Modifier.testTag(TvPlayerPlaylistLastItemTag)
if (isEntryItem) { } else {
Modifier 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
}
)
)
}
} }
} }
} }
@@ -169,25 +160,49 @@ private fun TvQueueRowCard(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val borderColor = when { val scale by animateFloatAsState(
isFocused -> scheme.primary targetValue = if (isFocused) 1.06f else 1f,
isCurrent -> scheme.primary.copy(alpha = 0.55f) label = "queueCardScale"
else -> scheme.outlineVariant.copy(alpha = 0.35f) )
} val borderColor by animateColorAsState(
val backgroundColor = when { targetValue = when {
isFocused -> scheme.primary.copy(alpha = 0.18f) isFocused -> scheme.primary
isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f) isCurrent -> scheme.tertiary
else -> scheme.surfaceVariant.copy(alpha = 0.62f) 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( Column(
modifier = modifier modifier = modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.border( .border(
width = if (isFocused) 2.dp else 1.dp, width = when {
isFocused -> 3.dp
isCurrent -> 2.dp
else -> 1.dp
},
color = borderColor, color = borderColor,
shape = RoundedCornerShape(18.dp) shape = shape
) )
.clip(RoundedCornerShape(18.dp)) .clip(shape)
.background(backgroundColor) .background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
@@ -207,37 +222,34 @@ private fun TvQueueRowCard(
} }
} }
.clickable { onClick() } .clickable { onClick() }
.padding(12.dp), .padding(4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
TvQueueArtwork( TvQueueArtwork(
artworkUrl = item.artworkUrl, artworkUrl = item.artworkUrl,
isCurrent = isCurrent,
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(
if (isCurrent) { text = item.title,
Text( color = contentColor,
text = "Current", style = MaterialTheme.typography.bodyMedium,
color = scheme.primary, fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
style = MaterialTheme.typography.labelMedium, maxLines = 1,
fontWeight = FontWeight.SemiBold overflow = TextOverflow.Ellipsis,
) modifier = Modifier.padding(horizontal = 8.dp)
} )
Text(
text = item.title,
color = scheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
@Composable @Composable
private fun TvQueueArtwork( private fun TvQueueArtwork(
artworkUrl: String?, artworkUrl: String?,
isCurrent: Boolean,
badgeColor: Color,
badgeContentColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -268,5 +280,30 @@ private fun TvQueueArtwork(
.background(scheme.surfaceContainerHigh) .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

@@ -22,7 +22,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.TimedMarker import hu.bbara.purefin.core.player.model.TimedMarker
@Composable @Composable
internal fun TvPlayerSeekBar( internal fun TvPlayerSeekBar(

View File

@@ -1,25 +1,30 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.HighQuality
import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -44,8 +49,8 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button" internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button" internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
@@ -211,23 +216,42 @@ private fun TvTrackOptionRow(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } 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( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then( .border(
if (isFocused) { width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp)) color = borderColor,
} else { shape = shape
Modifier
}
)
.clip(RoundedCornerShape(12.dp))
.background(
if (isFocused) scheme.primary.copy(alpha = 0.3f)
else if (selected) scheme.primary.copy(alpha = 0.15f)
else scheme.surfaceVariant.copy(alpha = 0.6f)
) )
.clip(shape)
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) { if (event.type != KeyEventType.KeyDown) {
@@ -244,11 +268,25 @@ private fun TvTrackOptionRow(
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 20.dp, vertical = 14.dp) .padding(horizontal = 20.dp, vertical = 14.dp)
) { ) {
Text( Row(
text = label, modifier = Modifier.fillMaxWidth(),
color = scheme.onSurface, horizontalArrangement = Arrangement.SpaceBetween,
style = MaterialTheme.typography.bodyLarge, verticalAlignment = Alignment.CenterVertically
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal ) {
) 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

@@ -8,21 +8,22 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.content.series.SeriesViewModel 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.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
@@ -33,25 +34,29 @@ import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable @Composable
fun TvSeriesScreen( fun TvSeriesScreen(
series: SeriesDto, series: SeriesDto,
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null, focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel() viewModel: SeriesViewModel = hiltViewModel()
) { ) {
LaunchedEffect(series.id) { LaunchedEffect(series) {
viewModel.selectSeries(series.id) viewModel.selectSeries(series)
} }
val series = viewModel.series.collectAsState() val series = viewModel.series.collectAsStateWithLifecycle()
val seriesData = series.value val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null) {
TvSeriesScreenContent( TvSeriesScreenContent(
series = seriesData, series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId, focusedEpisodeId = focusedEpisodeId,
modifier = modifier modifier = modifier
) )
@@ -63,28 +68,35 @@ fun TvSeriesScreen(
@Composable @Composable
internal fun TvSeriesScreenContent( internal fun TvSeriesScreenContent(
series: Series, series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null, focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() } val defaultSeason = series.defaultSeason(focusedSeasonId)
val focusedEpisode = remember(series.id, focusedEpisodeId) { var selectedSeasonId by remember(series.id, focusedSeasonId) {
focusedEpisodeId?.let { id -> mutableStateOf(defaultSeason?.id)
series.seasons
.flatMap { it.episodes }
.firstOrNull { it.id == id }
}
} }
val initialEpisode = focusedEpisode ?: nextUpEpisode val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
var selectedSeason by remember(series.id, initialEpisode?.seasonId) { val initialFocusSeasonId = defaultSeason?.id
mutableStateOf(series.defaultSeason(initialEpisode)) 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)
} }
val firstContentFocusRequester = remember { FocusRequester() } var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false)
}
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
initialFocusSeason?.episodes?.isEmpty() == true &&
initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, initialEpisode?.id) { LaunchedEffect(series.id, selectedSeason?.id) {
if (initialEpisode != null) return@LaunchedEffect selectedSeason?.let { selectSeason(series.id, it.id) }
withFrameNanos { }
firstContentFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
@@ -106,39 +118,55 @@ internal fun TvSeriesScreenContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
TvSeasonTabs( if (selectedSeason != null) {
seasons = series.seasons, TvSeasonTabs(
selectedSeason = selectedSeason, seasons = series.allSeasons,
firstItemFocusRequester = firstContentFocusRequester, selectedSeason = selectedSeason,
firstItemTestTag = SeriesFirstSeasonTabTag, selectedItemFocusRequester = seasonTabFocusRequester,
onSelect = { selectedSeason = it }, firstItemTestTag = SeriesFirstSeasonTabTag,
modifier = Modifier.fillMaxWidth() requestSelectedItemFocus = initialFocusedEpisodeId == null &&
) !waitingForInitialEpisodes &&
Spacer(modifier = Modifier.height(16.dp)) !requestedInitialSeasonFocus,
TvEpisodeCarousel( onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
episodes = selectedSeason.episodes, onSelect = { selectedSeasonId = it.id },
onPlayEpisode = { onPlayEpisode(it.id) }, modifier = Modifier.fillMaxWidth()
focusedEpisodeId = initialEpisode?.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(nextUpEpisode: Episode?): Season { private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
if (nextUpEpisode != null) { if (focusedSeasonId != null) {
seasons.firstOrNull { it.id == nextUpEpisode.seasonId }?.let { return it } allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
} }
for (season in seasons) { return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
if (season.episodes.any { !it.watched }) {
return season
}
}
return seasons.first()
} }
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season -> private fun Season.nextUpEpisode(): Episode? {
season.episodes.firstOrNull { !it.watched } return episodes.firstOrNull { !it.watched } ?: episodes.firstOrNull()
} ?: seasons.firstOrNull()?.episodes?.firstOrNull() }
private fun Season.focusTargetEpisodeId(focusedEpisodeId: UUID?): UUID? {
val focusedEpisode = episodes.firstOrNull { it.id == focusedEpisodeId }
return focusedEpisode?.id ?: nextUpEpisode()?.id
}

View File

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

View File

@@ -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,6 +1,6 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
@@ -8,7 +8,7 @@ import hu.bbara.purefin.model.Series
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.core.image.ArtworkKind
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID

View File

@@ -5,7 +5,7 @@ import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.feature.browse.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals

View File

@@ -11,24 +11,29 @@ plugins {
android { android {
namespace = "hu.bbara.purefin" namespace = "hu.bbara.purefin"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "hu.bbara.purefin" applicationId = "hu.bbara.purefin"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 1000002
versionName = "0.1" versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app-debug/update.json"
}
release { release {
// Enables code-related app optimization. // Enables code-related app optimization.
isMinifyEnabled = true isMinifyEnabled = true
// Enables resource shrinking. // Enables resource shrinking.
isShrinkResources = true isShrinkResources = true
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app/update.json"
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
) )
@@ -40,6 +45,20 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
}
}
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.outputs.forEach { output ->
output.versionCode.set(providers.gradleProperty("purefinDebugVersionCode").map { it.toInt() })
}
}
onVariants(selector().withBuildType("release")) { variant ->
variant.outputs.forEach { output ->
output.versionCode.set(providers.gradleProperty("purefinReleaseVersionCode").map { it.toInt() })
}
} }
} }
@@ -57,6 +76,7 @@ dependencies {
implementation(project(":core-ui")) implementation(project(":core-ui"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

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

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".PurefinApplication" android:name=".PurefinApplication"
@@ -16,7 +18,11 @@
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">
<meta-data
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
android:value="${updateManifestUrl}" />
<activity <activity
android:name=".PurefinActivity" android:name=".PurefinActivity"
android:exported="true" android:exported="true"
@@ -33,9 +39,12 @@
android:exported="false" android:exported="false"
android:theme="@style/Theme.Purefin" /> android:theme="@style/Theme.Purefin" />
<service <service
android:name="hu.bbara.purefin.download.PurefinDownloadService" android:name="hu.bbara.purefin.core.download.PurefinDownloadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<receiver
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -16,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
@@ -38,17 +41,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.data.SessionBootstrapper import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalSharedTransitionScope import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.navigation.NavigationCommand import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -144,7 +148,8 @@ class PurefinActivity : ComponentActivity() {
navigationManager: NavigationManager navigationManager: NavigationManager
) { ) {
var sessionLoaded by remember { mutableStateOf(false) } 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)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
userSessionRepository.isLoggedIn.collect { userSessionRepository.isLoggedIn.collect {
@@ -153,11 +158,11 @@ class PurefinActivity : ComponentActivity() {
} }
if (!sessionLoaded) { if (!sessionLoaded) {
BackHandler {
showExitDialog = true
}
PurefinWaitingScreen(modifier = Modifier.fillMaxSize()) PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
return } else if (isLoggedIn) {
}
if (isLoggedIn) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route> val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) } var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
@@ -169,7 +174,13 @@ class PurefinActivity : ComponentActivity() {
LaunchedEffect(navigationManager, backStack) { LaunchedEffect(navigationManager, backStack) {
navigationManager.commands.collect { command -> navigationManager.commands.collect { command ->
when (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.Navigate -> backStack.add(command.route)
is NavigationCommand.ReplaceAll -> { is NavigationCommand.ReplaceAll -> {
backStack.clear() backStack.clear()
@@ -179,6 +190,10 @@ class PurefinActivity : ComponentActivity() {
} }
} }
BackHandler(enabled = backStack.size == 1) {
showExitDialog = true
}
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavigationManager provides navigationManager, LocalNavigationManager provides navigationManager,
@@ -191,8 +206,17 @@ class PurefinActivity : ComponentActivity() {
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { navigationManager.pop() }, onBack = {
modifier = Modifier.fillMaxSize().background(backgroundDark), if (backStack.size > 1) {
navigationManager.pop()
} else {
showExitDialog = true
}
},
modifier = Modifier
.fillMaxSize()
.background(backgroundDark)
.semantics { testTagsAsResourceId = true },
transitionSpec = { transitionSpec = {
fadeIn( fadeIn(
animationSpec = tween( animationSpec = tween(
@@ -227,7 +251,19 @@ class PurefinActivity : ComponentActivity() {
} }
} }
} else { } else {
LoginScreen() BackHandler {
showExitDialog = true
}
LoginScreen(
modifier = Modifier.semantics { testTagsAsResourceId = true }
)
}
if (showExitDialog) {
ExitAppDialog(
onCloseConfirmed = { finishAndRemoveTask() },
onDismiss = { showExitDialog = false }
)
} }
} }
} }

View File

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

View File

@@ -6,7 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.appRouteEntryBuilder import hu.bbara.purefin.navigation.appRouteEntryBuilder
@Module @Module

View File

@@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.PlaybackProfileFamily import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View File

@@ -6,6 +6,8 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> = val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") } staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -3,12 +3,14 @@ package hu.bbara.purefin.navigation
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.LocalNavAnimatedContentScope
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.screen.library.LibraryScreen import hu.bbara.purefin.ui.screen.library.LibraryScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen import hu.bbara.purefin.ui.screen.AppScreen
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
import hu.bbara.purefin.ui.screen.home.components.search.SearchScreen import hu.bbara.purefin.ui.screen.home.components.search.SearchScreen
import hu.bbara.purefin.ui.screen.movie.MovieScreen import hu.bbara.purefin.ui.screen.movie.MovieScreen
import hu.bbara.purefin.ui.screen.settings.SettingsScreen
import hu.bbara.purefin.ui.screen.series.SeriesScreen import hu.bbara.purefin.ui.screen.series.SeriesScreen
fun EntryProviderScope<Route>.appRouteEntryBuilder() { fun EntryProviderScope<Route>.appRouteEntryBuilder() {
@@ -53,4 +55,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
entry<Route.LoginRoute> { entry<Route.LoginRoute> {
LoginScreen() LoginScreen()
} }
entry<Route.SettingsRoute> {
SettingsScreen()
}
} }

View File

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

View File

@@ -1,10 +1,15 @@
package hu.bbara.purefin.ui.common.card package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -12,13 +17,27 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -26,7 +45,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaAction
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun MediaImageCard( fun MediaImageCard(
imageUrl: String?, imageUrl: String?,
@@ -34,6 +55,7 @@ fun MediaImageCard(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
popupActions: List<MediaAction> = emptyList(),
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
shapeSize: Dp = 12.dp, shapeSize: Dp = 12.dp,
imageAspectRatio: Float = 16f / 10f, imageAspectRatio: Float = 16f / 10f,
@@ -45,11 +67,19 @@ fun MediaImageCard(
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(shapeSize) val shape = RoundedCornerShape(shapeSize)
var showBottomSheet by remember { mutableStateOf(false) }
Card( Card(
onClick = onClick,
shape = shape, shape = shape,
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer), colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.fillMaxWidth() modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = if (popupActions.isNotEmpty()) {
{ showBottomSheet = true }
} else null
)
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box( Box(
@@ -67,26 +97,69 @@ fun MediaImageCard(
) )
imageOverlay() imageOverlay()
} }
Column(modifier = Modifier.padding(textPadding)) { Row(
Text( modifier = Modifier.fillMaxWidth(),
text = title, verticalAlignment = Alignment.CenterVertically,
style = titleStyle, horizontalArrangement = Arrangement.SpaceBetween
fontWeight = FontWeight.SemiBold, ) {
maxLines = 1, Column(
overflow = TextOverflow.Ellipsis modifier = Modifier
) .weight(1f)
subtitle .padding(textPadding)
?.takeIf { it.isNotBlank() } ) {
?.let { text -> Text(
Spacer(modifier = Modifier.height(4.dp)) text = title,
Text( style = titleStyle,
text = text, fontWeight = FontWeight.SemiBold,
style = subtitleStyle, maxLines = 1,
color = scheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis
maxLines = 1, )
overflow = TextOverflow.Ellipsis subtitle
?.takeIf { it.isNotBlank() }
?.let { text ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
style = subtitleStyle,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (popupActions.isNotEmpty()) {
IconButton(
onClick = { showBottomSheet = true },
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "More actions"
) )
} }
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
popupActions.forEach { action ->
ListItem(
headlineContent = { Text(text = action.name) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable {
action.onClick()
showBottomSheet = false
}
)
}
} }
} }
} }

View File

@@ -29,10 +29,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.model.EpisodeUiModel import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel import hu.bbara.purefin.core.model.SeriesUiModel
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID import java.util.UUID
@@ -117,7 +117,6 @@ fun PosterCardContent(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(indicatorPadding), .padding(indicatorPadding),
watched = model.watched, watched = model.watched,
started = (model.progress ?: 0f) > 0f
) )
} }
else -> Unit else -> Unit

View File

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

View File

@@ -1,51 +0,0 @@
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
fun MediaHero(
imageUrl: String,
backgroundColor: Color,
heightFraction: Float = 0.4f,
modifier: Modifier = Modifier,
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val heroHeight = screenHeight * heightFraction
Box(
modifier = modifier
.height(heroHeight)
.background(backgroundColor)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
backgroundColor.copy(alpha = 0.5f),
backgroundColor
)
)
)
)
}
}

View File

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

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.ui.model
data class MediaAction(
val name: String,
val onClick: () -> Unit
)

View File

@@ -9,30 +9,45 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@Composable @Composable
fun AppBottomBar( fun AppBottomBar(
selectedTab: Int, selectedTab: Int,
isOnline: Boolean,
onTabSelected: (Int) -> Unit onTabSelected: (Int) -> Unit
) { ) {
NavigationBar { NavigationBar(
modifier = Modifier.testTag(BottomNavigationTag)
) {
if (isOnline) {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { onTabSelected(0) },
modifier = Modifier.testTag(BottomNavigationHomeTag),
icon = { Icon(Icons.Outlined.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { onTabSelected(1) },
modifier = Modifier.testTag(BottomNavigationLibrariesTag),
icon = { Icon(Icons.Outlined.Collections, contentDescription = "Libraries") },
label = { Text("Libraries") }
)
}
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 0, selected = !isOnline || selectedTab == 2,
onClick = { onTabSelected(0) },
icon = { Icon(Icons.Outlined.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { onTabSelected(1) },
icon = { Icon(Icons.Outlined.Collections, contentDescription = "Libraries") },
label = { Text("Libraries") }
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { onTabSelected(2) }, onClick = { onTabSelected(2) },
modifier = Modifier.testTag(BottomNavigationDownloadsTag),
icon = { Icon(Icons.Outlined.Download, contentDescription = "Downloads") }, icon = { Icon(Icons.Outlined.Download, contentDescription = "Downloads") },
label = { Text("Downloads") } label = { Text("Downloads") }
) )
} }
} }
internal const val BottomNavigationTag = "bottom-navigation"
internal const val BottomNavigationHomeTag = "bottom-navigation-home"
internal const val BottomNavigationLibrariesTag = "bottom-navigation-libraries"
internal const val BottomNavigationDownloadsTag = "bottom-navigation-downloads"

View File

@@ -1,24 +1,32 @@
package hu.bbara.purefin.ui.screen package hu.bbara.purefin.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel import hu.bbara.purefin.core.feature.browse.home.AppViewModel
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.download.DownloadsScreen import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen import hu.bbara.purefin.ui.screen.home.HomeScreen
@@ -28,29 +36,52 @@ import kotlinx.serialization.Serializable
@Composable @Composable
fun AppScreen( fun AppScreen(
viewModel: AppViewModel = hiltViewModel(), viewModel: AppViewModel = hiltViewModel(),
updateViewModel: AppUpdateViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val libraries by viewModel.libraries.collectAsState() val libraries by viewModel.libraries.collectAsStateWithLifecycle()
val libraryContent by viewModel.latestLibraryContent.collectAsState() val libraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsState() val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
val snackbarHostState = remember { SnackbarHostState() }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute> val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
val selectedTab = currentRoute.toTabIndex() val selectedTab = currentRoute.toTabIndex()
LaunchedEffect(isOnline, currentRoute, backStack) {
if (!isOnline && currentRoute != AppTabRoute.Downloads) {
backStack.clear()
backStack.add(AppTabRoute.Downloads)
}
}
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
viewModel.onResumed() viewModel.onResumed()
onPauseOrDispose { } onPauseOrDispose { }
} }
val onTabSelected = remember(backStack) { LaunchedEffect(updateViewModel) {
updateViewModel.checkForUpdatesOnAppOpen()
}
LaunchedEffect(updateViewModel) {
updateViewModel.snackbarMessages.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
val onTabSelected = remember(backStack, isOnline) {
{ selectedIndex: Int -> { selectedIndex: Int ->
val route = selectedIndex.toAppTabRoute() val route = if (isOnline) selectedIndex.toAppTabRoute() else AppTabRoute.Downloads
if (backStack.lastOrNull() != route) { if (backStack.lastOrNull() != route) {
backStack.add(route) backStack.add(route)
} }
@@ -74,11 +105,16 @@ fun AppScreen(
library.name library.name
) )
}, },
onMarkWatched = viewModel::markAsWatched,
onProfileClick = {}, onProfileClick = {},
onSettingsClick = {}, onCheckForUpdates = { updateViewModel.checkForUpdates() },
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = viewModel::openSettings,
onLogoutClick = viewModel::logout, onLogoutClick = viewModel::logout,
onSearchClick = viewModel::openSearch, onSearchClick = viewModel::openSearch,
snackbarHostState = snackbarHostState,
selectedTab = selectedTab, selectedTab = selectedTab,
isOnline = isOnline,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -89,6 +125,7 @@ fun AppScreen(
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) }, onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) },
onSearchClick = viewModel::openSearch, onSearchClick = viewModel::openSearch,
selectedTab = selectedTab, selectedTab = selectedTab,
isOnline = isOnline,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -96,12 +133,19 @@ fun AppScreen(
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) { entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
DownloadsScreen( DownloadsScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
isOnline = isOnline,
isCheckingConnection = isCheckingConnection,
onCheckConnection = viewModel::checkConnection,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
} }
BackHandler(enabled = backStack.size == 1) {
navigationManager.pop()
}
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { onBack = {
@@ -120,6 +164,14 @@ fun AppScreen(
entryProvider = tabEntryProvider, entryProvider = tabEntryProvider,
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
) )
availableUpdate?.let { update ->
UpdateAvailableDialog(
update = update,
onAccept = updateViewModel::acceptUpdate,
onDecline = updateViewModel::declineUpdate
)
}
} }
@Serializable @Serializable
@@ -153,4 +205,35 @@ private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
private fun Map<String, Any>.appTabIndex(): Int = private fun Map<String, Any>.appTabIndex(): Int =
this[APP_TAB_INDEX_METADATA] as? Int ?: 0 this[APP_TAB_INDEX_METADATA] as? Int ?: 0
@Composable
private fun UpdateAvailableDialog(
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 $versionLabel is available.\n\nVersion code: ${update.versionCode}"
} else {
"Purefin $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")
}
}
)
}
private const val APP_TAB_INDEX_METADATA = "app_tab_index" private const val APP_TAB_INDEX_METADATA = "app_tab_index"

View File

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

View File

@@ -22,20 +22,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.downloads.ActiveDownloadItem import hu.bbara.purefin.core.feature.downloads.ActiveDownloadItem
@Composable @Composable
internal fun DownloadingItemRow( internal fun DownloadingItemRow(
item: ActiveDownloadItem, item: ActiveDownloadItem,
onCancel: (String) -> Unit, onCancel: (String) -> Unit,
modifier: Modifier = Modifier
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
@@ -91,7 +93,10 @@ internal fun DownloadingItemRow(
) )
} }
} }
IconButton(onClick = { onCancel(item.contentId) }) { IconButton(
onClick = { onCancel(item.contentId) },
modifier = Modifier.testTag("$DownloadsCancelButtonTagPrefix${item.contentId}")
) {
Icon( Icon(
imageVector = Icons.Outlined.Close, imageVector = Icons.Outlined.Close,
contentDescription = "Cancel download", contentDescription = "Cancel download",
@@ -101,3 +106,5 @@ internal fun DownloadingItemRow(
} }
} }
} }
internal const val DownloadsCancelButtonTagPrefix = "downloads-cancel-button-"

View File

@@ -5,40 +5,46 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
import hu.bbara.purefin.ui.common.card.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.feature.downloads.DownloadsViewModel
@Composable @Composable
fun DownloadsContent( fun DownloadsContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: DownloadsViewModel = hiltViewModel(), viewModel: DownloadsViewModel = hiltViewModel(),
) { ) {
val downloads = viewModel.downloads.collectAsState(emptyList()) val downloads = viewModel.downloads.collectAsStateWithLifecycle(initialValue = emptyList())
val activeDownloads = viewModel.activeDownloads.collectAsState() val activeDownloads = viewModel.activeDownloads.collectAsStateWithLifecycle()
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty() val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
if (isEmpty) { if (isEmpty) {
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
.fillMaxSize()
.testTag(DownloadsEmptyStateTag),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -62,7 +68,9 @@ fun DownloadsContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background) modifier = modifier
.testTag(DownloadsGridTag)
.background(MaterialTheme.colorScheme.background)
) { ) {
if (activeDownloads.value.isNotEmpty()) { if (activeDownloads.value.isNotEmpty()) {
item(key = "downloading-header", span = { GridItemSpan(maxLineSpan) }) { item(key = "downloading-header", span = { GridItemSpan(maxLineSpan) }) {
@@ -80,7 +88,8 @@ fun DownloadsContent(
) { item -> ) { item ->
DownloadingItemRow( DownloadingItemRow(
item = item, item = item,
onCancel = { viewModel.cancelDownload(it) } onCancel = { viewModel.cancelDownload(it) },
modifier = Modifier.testTag("$DownloadsActiveItemTagPrefix${item.contentId}")
) )
} }
if (downloads.value.isNotEmpty()) { if (downloads.value.isNotEmpty()) {
@@ -95,13 +104,22 @@ fun DownloadsContent(
} }
} }
} }
items(downloads.value, key = { item -> item.id }) { item -> items(downloads.value, key = { item -> item.media.id }) { item ->
PosterCard( Column(
item = item, modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
onMovieSelected = viewModel::onMovieSelected, ) {
onSeriesSelected = viewModel::onSeriesSelected, PosterCard(
onEpisodeSelected = { _, _, _ -> }, item = item.media,
) onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
)
}
} }
} }
} }
internal const val DownloadsEmptyStateTag = "downloads-empty-state"
internal const val DownloadsGridTag = "downloads-grid"
internal const val DownloadsActiveItemTagPrefix = "downloads-active-item-"
internal const val DownloadsItemTagPrefix = "downloads-item-"

View File

@@ -1,54 +1,44 @@
package hu.bbara.purefin.ui.screen.episode package hu.bbara.purefin.ui.screen.episode
import android.Manifest import android.content.Intent
import android.os.Build import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.ui.common.media.MediaHero import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.download.DownloadState import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeWatchedButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.image.ArtworkKind
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -61,28 +51,18 @@ fun EpisodeScreen(
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) } val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(episode)
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
} }
val episode = viewModel.episode.collectAsState() val episode = viewModel.episode.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsState() val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val requestNotificationPermission = rememberNotificationPermissionGate()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val onDownloadClick = { val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU if (downloadState.value is DownloadState.NotDownloaded) {
&& downloadState.value is DownloadState.NotDownloaded requestNotificationPermission {
) { viewModel.onDownloadClick()
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else { } else {
viewModel.onDownloadClick() viewModel.onDownloadClick()
} }
@@ -103,127 +83,94 @@ fun EpisodeScreen(
}, },
downloadState = downloadState.value, downloadState = downloadState.value,
onBack = viewModel::onBack, onBack = viewModel::onBack,
onSeriesClick = viewModel::onSeriesClick,
onDownloadClick = onDownloadClick, onDownloadClick = onDownloadClick,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier modifier = modifier
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun EpisodeScreenInternal( private fun EpisodeScreenInternal(
episode: Episode, episode: Episode,
topBarShortcut: EpisodeTopBarShortcut?, topBarShortcut: EpisodeTopBarShortcut?,
downloadState: DownloadState, downloadState: DownloadState,
onBack: () -> Unit, onBack: () -> Unit,
onSeriesClick: () -> Unit,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive() val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
Scaffold( MediaDetailScaffold(
uiModel = episode.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
),
modifier = modifier, modifier = modifier,
containerColor = MaterialTheme.colorScheme.background, topBar = { scrollBehavior ->
topBar = { EpisodeTopBar(
if (!isHomeMediaTransitionActive) { shortcut = topBarShortcut,
EpisodeTopBar( onBack = onBack,
shortcut = topBarShortcut, scrollBehavior = scrollBehavior
onBack = onBack,
onSeriesClick = onSeriesClick,
modifier = Modifier
)
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
EpisodeHeroSection(episode = episode)
if (!isHomeMediaTransitionActive) {
EpisodeDetails(
episode = episode,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = innerPadding.calculateBottomPadding())
)
}
}
}
}
@Composable
private fun EpisodeHeroSection(
episode: Episode,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val sectionHeight = screenHeight * 0.4f
Box(
modifier = modifier
.homeMediaSharedBoundsDestination()
.fillMaxWidth()
.height(sectionHeight)
) {
MediaHero(
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = scheme.background,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
scheme.background.copy(alpha = 0.5f),
scheme.background
)
)
)
)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomStart)
.padding(16.dp)
) {
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp
) )
Spacer(modifier = Modifier.height(4.dp)) },
Text(
text = "Episode ${episode.index}",
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
EpisodeMetaChips(episode = episode)
}
}
}
@Composable
private fun EpisodeMetaChips(episode: Episode) {
MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
highlightedItem = episode.format
) )
} }
private fun Episode.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title,
subtitle = "Episode $index",
metadataItems = listOf(releaseDate, rating, runtime, format),
highlightedMetadataItem = format,
actions = MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(progress, watched),
progress = mediaPlaybackProgress(progress),
onClick = onPlayClick,
testTag = EpisodePlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.MarkAsWatched(
watched = watched,
onClick = { onMarkAsWatched(!watched) },
testTag = EpisodeWatchedButtonTag
),
MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState,
onClick = onDownloadClick,
testTag = EpisodeDownloadButtonTag
)
),
dividerThickness = 2.dp
),
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
playbackSettings = MediaDetailPlaybackSettingsUiModel(
audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(members = cast)
} else {
null
}
)
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun EpisodeScreenPreview() { private fun EpisodeScreenPreview() {
@@ -233,7 +180,6 @@ private fun EpisodeScreenPreview() {
topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}), topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}),
downloadState = DownloadState.Downloading(progressPercent = 0.42f), downloadState = DownloadState.Downloading(progressPercent = 0.42f),
onBack = {}, onBack = {},
onSeriesClick = {},
onDownloadClick = {} onDownloadClick = {}
) )
} }

View File

@@ -1,49 +1,27 @@
package hu.bbara.purefin.ui.screen.episode.components package hu.bbara.purefin.ui.screen.episode.components
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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 androidx.compose.ui.unit.sp
import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.GhostIconButton import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
internal sealed interface EpisodeTopBarShortcut { internal sealed interface EpisodeTopBarShortcut {
val label: String val label: String
@@ -54,137 +32,60 @@ internal sealed interface EpisodeTopBarShortcut {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun EpisodeTopBar( internal fun EpisodeTopBar(
shortcut: EpisodeTopBarShortcut?, shortcut: EpisodeTopBarShortcut?,
onBack: () -> Unit, onBack: () -> Unit,
onSeriesClick: () -> Unit, scrollBehavior: TopAppBarScrollBehavior? = null,
modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme DefaultTopBar(
Row( leftActions = {
modifier = modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
GhostIconButton( GhostIconButton(
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack onClick = onBack
) )
when { when (shortcut) {
shortcut != null -> { is EpisodeTopBarShortcut.Series -> {
Box( TextButton(
onClick = shortcut.onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier modifier = Modifier
.height(52.dp) .height(52.dp)
.clickable(onClick = shortcut.onClick), .testTag(EpisodeSeriesButtonTag)
contentAlignment = Alignment.Center .clip(CircleShape)
) { ) {
Text( Text(
text = shortcut.label, text = shortcut.label,
color = scheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp, fontSize = 18.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clip(CircleShape)
.background(scheme.background.copy(alpha = 0.65f))
.padding(horizontal = 16.dp, vertical = 10.dp)
) )
} }
} }
null -> {}
} }
} },
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { rightActions = {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) GhostIconButton(
} icon = Icons.Outlined.MoreVert,
} contentDescription = "More",
onClick = { })
},
withIcon = false,
scrollBehavior = scrollBehavior
)
} }
@Composable internal const val EpisodeSeriesButtonTag = "episode-series-button"
internal fun EpisodeDetails( internal const val EpisodePlayButtonTag = "episode-play-button"
episode: Episode, internal const val EpisodeDownloadButtonTag = "episode-download-button"
downloadState: DownloadState, internal const val EpisodeWatchedButtonTag = "episode-watched-button"
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
Column(modifier = modifier) {
MediaSynopsis(
synopsis = episode.synopsis
)
Spacer(modifier = Modifier.height(24.dp))
Row() {
MediaResumeButton(
text = mediaPlayButtonText(episode.progress, episode.watched),
progress = mediaPlaybackProgress(episode.progress),
onClick = playAction,
modifier = Modifier.sizeIn(maxWidth = 200.dp)
)
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = 2.dp,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = 48.dp,
onClick = onDownloadClick
)
}
}
Spacer(modifier = Modifier.height(24.dp))
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
//TODO fix it
audioTrack = "ENG",
subtitles = "ENG"
)
Spacer(modifier = Modifier.height(24.dp))
if (episode.cast.isNotEmpty()) {
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
//TODO use it
// MediaCastRow(
// cast = episode.cast
// )
}
}
}

View File

@@ -2,17 +2,23 @@ package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.model.LibraryUiModel import androidx.compose.ui.input.nestedscroll.nestedScroll
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeContent import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import java.util.UUID import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
libraries: List<LibraryUiModel>, libraries: List<LibraryUiModel>,
@@ -24,30 +30,43 @@ fun HomeScreen(
onRefresh: () -> Unit, onRefresh: () -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onMarkWatched: (MediaUiModel, Boolean) -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
snackbarHostState: SnackbarHostState,
selectedTab: Int, selectedTab: Int,
isOnline: Boolean,
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold( Scaffold(
modifier = modifier modifier = modifier
.fillMaxSize(), .fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
HomeTopBar( HomeTopBar(
onSearchClick = onSearchClick, onSearchClick = onSearchClick,
onProfileClick = onProfileClick, onProfileClick = onProfileClick,
onCheckForUpdates = onCheckForUpdates,
isCheckingForUpdates = isCheckingForUpdates,
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onLogoutClick = onLogoutClick onLogoutClick = onLogoutClick,
scrollBehavior = topBarScrollBehavior
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
AppBottomBar( AppBottomBar(
selectedTab = selectedTab, selectedTab = selectedTab,
isOnline = isOnline,
onTabSelected = onTabSelected onTabSelected = onTabSelected
) )
} }
@@ -63,6 +82,7 @@ fun HomeScreen(
onMediaSelected = onMediaSelected, onMediaSelected = onMediaSelected,
onLibrarySelected = onLibrarySelected, onLibrarySelected = onLibrarySelected,
onBrowseLibrariesClick = { onTabSelected(1) }, onBrowseLibrariesClick = { onTabSelected(1) },
onMarkAsWatched = onMarkWatched,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
} }

View File

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

View File

@@ -20,9 +20,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
@@ -42,6 +43,7 @@ fun HomeContent(
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onBrowseLibrariesClick: () -> Unit, onBrowseLibrariesClick: () -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -79,7 +81,9 @@ fun HomeContent(
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
modifier = modifier.fillMaxSize() modifier = modifier
.fillMaxSize()
.testTag(HomeContentTag)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -88,7 +92,9 @@ fun HomeContent(
) { ) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.testTag(HomeContentViewportTag),
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp), contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
@@ -96,7 +102,8 @@ fun HomeContent(
item(key = "featured") { item(key = "featured") {
SuggestionsSection( SuggestionsSection(
items = suggestions, items = suggestions,
onItemOpen = { item -> onMediaSelected(item) } onItemOpen = { item -> onMediaSelected(item) },
modifier = Modifier.testTag(HomeFeaturedSectionTag)
) )
} }
} }
@@ -105,17 +112,21 @@ fun HomeContent(
item(key = "continue-watching") { item(key = "continue-watching") {
ContinueWatchingSection( ContinueWatchingSection(
items = continueWatching, items = continueWatching,
onMediaSelected = onMediaSelected onMarkAsWatched = onMarkAsWatched,
onMediaSelected = onMediaSelected,
modifier = Modifier.testTag(HomeContinueWatchingSectionTag)
) )
} }
} }
if (nextUp.isNotEmpty()) { if (nextUp.isNotEmpty()) {
item(key = "next-up") { item(key = "next-up") {
NextUpSection( NextUpSection(
items = nextUp, items = nextUp,
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
) onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag(HomeNextUpSectionTag)
)
} }
} }
@@ -127,7 +138,9 @@ fun HomeContent(
library = library, library = library,
items = libraryContent[library.id].orEmpty(), items = libraryContent[library.id].orEmpty(),
onLibrarySelected = onLibrarySelected, onLibrarySelected = onLibrarySelected,
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
) )
} }
@@ -135,7 +148,8 @@ fun HomeContent(
item(key = "empty-state") { item(key = "empty-state") {
HomeEmptyState( HomeEmptyState(
onRefresh = onRefresh, onRefresh = onRefresh,
onBrowseLibrariesClick = onBrowseLibrariesClick onBrowseLibrariesClick = onBrowseLibrariesClick,
modifier = Modifier.testTag(HomeEmptyStateTag)
) )
} }
} }
@@ -143,3 +157,11 @@ fun HomeContent(
} }
} }
} }
internal const val HomeContentTag = "home-content"
internal const val HomeContentViewportTag = "home-content-viewport"
internal const val HomeFeaturedSectionTag = "home-section-featured"
internal const val HomeContinueWatchingSectionTag = "home-section-continue-watching"
internal const val HomeNextUpSectionTag = "home-section-next-up"
internal const val HomeLibrarySectionTagPrefix = "home-section-library-"
internal const val HomeEmptyStateTag = "home-empty-state"

View File

@@ -1,33 +1,35 @@
package hu.bbara.purefin.ui.screen.home.components package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onCheckForUpdates: () -> Unit,
isCheckingForUpdates: Boolean,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior? = null,
) { ) {
var isProfileMenuExpanded by remember { mutableStateOf(false) } var isProfileMenuExpanded by remember { mutableStateOf(false) }
DefaultTopBar( DefaultTopBar(
scrollBehavior = scrollBehavior,
rightActions = { rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick) DefaultTopBarSearchButton(onClick = onSearchClick)
Spacer(modifier = Modifier.size(12.dp))
DefaultTopBarIconButton( DefaultTopBarIconButton(
imageVector = Icons.Outlined.Person, imageVector = Icons.Outlined.Person,
contentDescription = "Profile", contentDescription = "Profile",
@@ -44,6 +46,14 @@ fun HomeTopBar(
onProfileClick() onProfileClick()
} }
) )
DropdownMenuItem(
text = { Text(if (isCheckingForUpdates) "Checking updates..." else "Check for updates") },
enabled = !isCheckingForUpdates,
onClick = {
isProfileMenuExpanded = false
onCheckForUpdates()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Settings") }, text = { Text("Settings") },
onClick = { onClick = {

View File

@@ -15,13 +15,15 @@ import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
internal fun ContinueWatchingCard( internal fun ContinueWatchingCard(
item: MediaUiModel, item: MediaUiModel,
sharedBoundsKey: String, sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -34,6 +36,16 @@ internal fun ContinueWatchingCard(
title = item.primaryText, title = item.primaryText,
subtitle = item.secondaryText, subtitle = item.secondaryText,
onClick = onClick, onClick = onClick,
popupActions = listOf(
MediaAction(
name = "Mark as watched",
onClick = { onMarkAsWatched(item, true) }
),
MediaAction(
name = "Mark as unwatched",
onClick = { onMarkAsWatched(item, false) }
)
),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 26.dp, shapeSize = 26.dp,
imageAspectRatio = 16f / 9f, imageAspectRatio = 16f / 9f,

View File

@@ -12,15 +12,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
fun ContinueWatchingSection( fun ContinueWatchingSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -40,16 +42,23 @@ fun ContinueWatchingSection(
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(HomeContinueWatchingRowTag),
state = listState state = listState
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
ContinueWatchingCard( ContinueWatchingCard(
item = item, item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id), sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeContinueWatchingItemTagPrefix$index")
) )
} }
} }
} }
} }
internal const val HomeContinueWatchingRowTag = "home-continue-watching-row"
internal const val HomeContinueWatchingItemTagPrefix = "home-continue-watching-item-"

View File

@@ -27,7 +27,7 @@ import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
internal fun SuggestionCard( internal fun SuggestionCard(

View File

@@ -21,9 +21,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
fun SuggestionsSection( fun SuggestionsSection(
@@ -48,13 +49,16 @@ fun SuggestionsSection(
pageSize = PageSize.Fixed(320.dp), pageSize = PageSize.Fixed(320.dp),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
pageSpacing = 16.dp, pageSpacing = 16.dp,
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.testTag(HomeFeaturedPagerTag)
) { page -> ) { page ->
val item = items[page] val item = items[page]
SuggestionCard( SuggestionCard(
item = item, item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id), sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id),
onClick = { onItemOpen(item) } onClick = { onItemOpen(item) },
modifier = Modifier.testTag("$HomeFeaturedItemTagPrefix$page")
) )
} }
if (items.size > 1) { if (items.size > 1) {
@@ -86,3 +90,6 @@ fun SuggestionsSection(
} }
} }
} }
internal const val HomeFeaturedPagerTag = "home-featured-pager"
internal const val HomeFeaturedItemTagPrefix = "home-featured-item-"

View File

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

View File

@@ -12,11 +12,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
fun LibraryPosterSection( fun LibraryPosterSection(
@@ -24,6 +25,7 @@ fun LibraryPosterSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -47,7 +49,9 @@ fun LibraryPosterSection(
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag("$HomeLibraryRowTagPrefix${library.id}"),
state = listState state = listState
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
@@ -57,9 +61,14 @@ fun LibraryPosterSection(
origin = "library-${library.id}-$index", origin = "library-${library.id}-$index",
mediaId = item.id mediaId = item.id
), ),
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
) )
} }
} }
} }
} }
internal const val HomeLibraryRowTagPrefix = "home-library-row-"
internal const val HomeLibraryItemTagPrefix = "home-library-item-"

View File

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

View File

@@ -12,15 +12,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.core.model.MediaUiModel
@Composable @Composable
fun NextUpSection( fun NextUpSection(
items: List<MediaUiModel>, items: List<MediaUiModel>,
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -40,16 +42,23 @@ fun NextUpSection(
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag(HomeNextUpRowTag),
state = listState state = listState
) { ) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
NextUpCard( NextUpCard(
uiModel = item, uiModel = item,
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id), sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
onMediaSelected = onMediaSelected onMediaSelected = onMediaSelected,
onMarkAsWatched = onMarkAsWatched,
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
) )
} }
} }
} }
} }
internal const val HomeNextUpRowTag = "home-next-up-row"
internal const val HomeNextUpItemTagPrefix = "home-next-up-item-"

View File

@@ -24,8 +24,8 @@ import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.search.SearchViewModel import hu.bbara.purefin.core.feature.search.SearchViewModel
import java.util.UUID import java.util.UUID
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
@@ -55,7 +55,7 @@ fun SearchOverlay(
BackHandler(onBack = onDismiss) BackHandler(onBack = onDismiss)
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
val searchResults by searchViewModel.searchResult.collectAsState() val searchResults by searchViewModel.searchResult.collectAsStateWithLifecycle()
val dismissInteractionSource = remember { MutableInteractionSource() } val dismissInteractionSource = remember { MutableInteractionSource() }
Box( Box(

View File

@@ -2,7 +2,7 @@ package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.feature.search.SearchResult import hu.bbara.purefin.core.feature.search.SearchResult
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard

View File

@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@@ -30,6 +29,7 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -39,15 +39,22 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -55,8 +62,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.feature.search.SearchResult import hu.bbara.purefin.core.feature.search.SearchResult
import hu.bbara.purefin.feature.search.SearchViewModel import hu.bbara.purefin.core.feature.search.SearchViewModel
import hu.bbara.purefin.model.Genre import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.MediaKind import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
@@ -64,6 +71,7 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import java.util.UUID import java.util.UUID
@@ -98,7 +106,7 @@ fun SearchScreen(
Modifier Modifier
} }
SearchFullScreenContent( SearchScreenContent(
query = query, query = query,
searchResults = searchResults, searchResults = searchResults,
genres = genres.sortedBy { it.name }, genres = genres.sortedBy { it.name },
@@ -126,9 +134,9 @@ fun SearchScreen(
) )
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SearchFullScreenContent( private fun SearchScreenContent(
query: String, query: String,
searchResults: List<SearchResult>, searchResults: List<SearchResult>,
genres: List<Genre>, genres: List<Genre>,
@@ -142,15 +150,18 @@ private fun SearchFullScreenContent(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold( Scaffold(
topBar = { SearchHeader( topBar = { SearchHeader(
onBack = onBack onBack = onBack,
scrollBehavior = topBarScrollBehavior
) }, ) },
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.testTag(SearchScreenTag)
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.background(scheme.background) .background(scheme.background)
.statusBarsPadding()
.padding(top = 24.dp)
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -217,13 +228,13 @@ private fun SearchResults(
} }
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SearchHeader( private fun SearchHeader(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier scrollBehavior: TopAppBarScrollBehavior? = null,
) { ) {
val scheme = MaterialTheme.colorScheme
DefaultTopBar( DefaultTopBar(
scrollBehavior = scrollBehavior,
leftActions = { leftActions = {
DefaultTopBarIconButton( DefaultTopBarIconButton(
imageVector = Icons.Outlined.ArrowBack, imageVector = Icons.Outlined.ArrowBack,
@@ -243,6 +254,11 @@ private fun SearchField(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
TextField( TextField(
value = query, value = query,
@@ -267,7 +283,10 @@ private fun SearchField(
}, },
trailingIcon = { trailingIcon = {
if (query.isNotBlank()) { if (query.isNotBlank()) {
IconButton(onClick = { onQueryChange("") }) { IconButton(
onClick = { onQueryChange("") },
modifier = Modifier.testTag(SearchClearButtonTag)
) {
Icon( Icon(
imageVector = Icons.Outlined.Close, imageVector = Icons.Outlined.Close,
contentDescription = "Clear search", contentDescription = "Clear search",
@@ -292,6 +311,8 @@ private fun SearchField(
), ),
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester)
.testTag(SearchQueryFieldTag)
.height(68.dp) .height(68.dp)
) )
} }
@@ -304,18 +325,23 @@ private fun SearchResultsGrid(
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxWidth() modifier = modifier
.fillMaxWidth()
.testTag(SearchResultsGridTag)
) { ) {
results.chunked(2).forEach { rowItems -> results.chunked(2).forEachIndexed { rowIndex, rowItems ->
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
rowItems.forEach { item -> rowItems.forEachIndexed { columnIndex, item ->
val itemIndex = rowIndex * 2 + columnIndex
SearchResultCard( SearchResultCard(
item = item, item = item,
onClick = { onResultClick(item) }, onClick = { onResultClick(item) },
modifier = Modifier.weight(1f) modifier = Modifier
.weight(1f)
.testTag("$SearchResultItemTagPrefix$itemIndex")
) )
} }
if (rowItems.size == 1) { if (rowItems.size == 1) {
@@ -373,6 +399,7 @@ private fun GenreSelector(
FilterChip( FilterChip(
selected = false, selected = false,
onClick = { showGenres = true }, onClick = { showGenres = true },
modifier = Modifier.testTag(SearchGenreSelectorTag),
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Outlined.Tune, imageVector = Icons.Outlined.Tune,
@@ -394,11 +421,12 @@ private fun GenreSelector(
) )
} }
) )
visibleGenres.forEach { genre -> visibleGenres.forEachIndexed { index, genre ->
GenreChip( GenreChip(
genre = genre, genre = genre,
selected = genre.name in selectedGenreNames, selected = genre.name in selectedGenreNames,
onClick = { onGenreSelected(genre) } onClick = { onGenreSelected(genre) },
modifier = Modifier.testTag("$SearchGenreChipTagPrefix$index")
) )
} }
} }
@@ -406,6 +434,7 @@ private fun GenreSelector(
if (showGenres) { if (showGenres) {
AlertDialog( AlertDialog(
onDismissRequest = { showGenres = false }, onDismissRequest = { showGenres = false },
modifier = Modifier.testTag(SearchGenreDialogTag),
title = { title = {
Text(text = "Select genres") Text(text = "Select genres")
}, },
@@ -418,11 +447,12 @@ private fun GenreSelector(
.heightIn(max = 360.dp) .heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
genres.forEach { genre -> genres.forEachIndexed { index, genre ->
GenreChip( GenreChip(
genre = genre, genre = genre,
selected = genre.name in selectedGenreNames, selected = genre.name in selectedGenreNames,
onClick = { onGenreSelected(genre) } onClick = { onGenreSelected(genre) },
modifier = Modifier.testTag("$SearchGenreDialogChipTagPrefix$index")
) )
} }
} }
@@ -477,11 +507,11 @@ private fun GenreChip(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun SearchFullScreenPreview() { private fun SearchScreenPreview() {
var query by rememberSaveable { mutableStateOf("du") } var query by rememberSaveable { mutableStateOf("du") }
AppTheme { AppTheme {
SearchFullScreenContent( SearchScreenContent(
query = query, query = query,
searchResults = previewSearchResults, searchResults = previewSearchResults,
genres = previewGenres, genres = previewGenres,
@@ -520,3 +550,13 @@ private val previewSearchResults = listOf(
type = MediaKind.MOVIE type = MediaKind.MOVIE
) )
) )
internal const val SearchScreenTag = "search-screen"
internal const val SearchQueryFieldTag = "search-query-field"
internal const val SearchClearButtonTag = "search-clear-button"
internal const val SearchResultsGridTag = "search-results-grid"
internal const val SearchResultItemTagPrefix = "search-result-item-"
internal const val SearchGenreSelectorTag = "search-genre-selector"
internal const val SearchGenreChipTagPrefix = "search-genre-chip-"
internal const val SearchGenreDialogTag = "search-genre-dialog"
internal const val SearchGenreDialogChipTagPrefix = "search-genre-dialog-chip-"

View File

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

View File

@@ -8,8 +8,9 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
@Composable @Composable
fun LibrariesContent( fun LibrariesContent(
@@ -17,7 +18,7 @@ fun LibrariesContent(
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier = modifier) { BoxWithConstraints(modifier = modifier.testTag(LibrariesOverviewGridTag)) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
LazyVerticalGrid( LazyVerticalGrid(
@@ -29,9 +30,13 @@ fun LibrariesContent(
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
LibraryListItem( LibraryListItem(
uiModel = item, uiModel = item,
onClick = { onLibrarySelected(item) } onClick = { onLibrarySelected(item) },
modifier = Modifier.testTag("$LibrariesOverviewItemTagPrefix${item.id}")
) )
} }
} }
} }
} }
internal const val LibrariesOverviewGridTag = "libraries-overview-grid"
internal const val LibrariesOverviewItemTagPrefix = "libraries-overview-item-"

View File

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

View File

@@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
@Composable @Composable
fun LibraryListItem( fun LibraryListItem(

View File

@@ -10,25 +10,32 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.browse.library.LibraryViewModel import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.navigation.LibraryDto 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.core.navigation.LibraryDto
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.card.MediaImageCard import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.EpisodeUiModel import hu.bbara.purefin.ui.model.MediaAction
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
library: LibraryDto, library: LibraryDto,
@@ -39,15 +46,19 @@ fun LibraryScreen(
viewModel.selectLibrary(libraryId = library.id) viewModel.selectLibrary(libraryId = library.id)
} }
val libraryItems = viewModel.contents.collectAsState() val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier
.testTag(LibraryScreenTag)
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
LibraryTopBar( LibraryTopBar(
onBack = { viewModel.onBack() } onBack = { viewModel.onBack() },
scrollBehavior = topBarScrollBehavior
) )
} }
) { innerPadding -> ) { innerPadding ->
@@ -57,7 +68,10 @@ fun LibraryScreen(
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(innerPadding) .padding(innerPadding)
) { ) {
LibraryPosterGrid(libraryItems = libraryItems.value) LibraryPosterGrid(
libraryItems = libraryItems.value,
onMarkAsWatched = viewModel::markAsWatched,
)
} }
} }
} }
@@ -66,9 +80,14 @@ fun LibraryScreen(
internal fun LibraryPosterGrid( internal fun LibraryPosterGrid(
libraryItems: List<MediaUiModel>, libraryItems: List<MediaUiModel>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
viewModel: LibraryViewModel = hiltViewModel() viewModel: LibraryViewModel = hiltViewModel()
) { ) {
BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(
modifier = modifier
.testTag(LibraryPosterGridTag)
.background(MaterialTheme.colorScheme.background)
) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp
LazyVerticalGrid( LazyVerticalGrid(
@@ -78,6 +97,15 @@ internal fun LibraryPosterGrid(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(libraryItems, key = { item -> item.id }) { item -> items(libraryItems, key = { item -> item.id }) { item ->
val popupActions = remember {
buildList {
if (item is MovieUiModel || item is EpisodeUiModel || item is SeriesUiModel) {
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(item, true) })
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(item, false) })
}
}
}
MediaImageCard( MediaImageCard(
imageUrl = item.primaryImageUrl, imageUrl = item.primaryImageUrl,
title = item.primaryText, title = item.primaryText,
@@ -88,7 +116,9 @@ internal fun LibraryPosterGrid(
is SeriesUiModel -> viewModel.onSeriesSelected(item.id) is SeriesUiModel -> viewModel.onSeriesSelected(item.id)
is EpisodeUiModel -> Unit is EpisodeUiModel -> Unit
} }
} },
popupActions = popupActions,
modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}")
) { ) {
when (item) { when (item) {
is MovieUiModel, is EpisodeUiModel -> { is MovieUiModel, is EpisodeUiModel -> {
@@ -98,7 +128,6 @@ internal fun LibraryPosterGrid(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp), .padding(8.dp),
watched = item.watched, watched = item.watched,
started = (item.progress ?: 0f) > 0f
) )
} }
else -> Unit else -> Unit
@@ -108,3 +137,7 @@ internal fun LibraryPosterGrid(
} }
} }
} }
internal const val LibraryScreenTag = "library-screen"
internal const val LibraryPosterGridTag = "library-poster-grid"
internal const val LibraryPosterItemTagPrefix = "library-poster-item-"

View File

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

View File

@@ -0,0 +1,305 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.foundation.background
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.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.Person
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.image.PurefinLogo
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Composable
fun LoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
Column(
modifier = modifier
.fillMaxSize()
.testTag(LoginContentTag)
.background(scheme.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
PurefinLogo(
color = scheme.primary,
modifier = Modifier.size(64.dp)
)
Text(
text = "Purefin",
color = scheme.onBackground,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = "Personal media, your way",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(28.dp))
state.errorMessage?.let { errorMessage ->
Text(
text = errorMessage,
color = scheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginErrorMessageTag)
.background(
scheme.errorContainer,
RoundedCornerShape(12.dp)
)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
}
when (state.phase) {
LoginContentPhase.ServerSearch -> ServerSearchContent(state, callbacks)
LoginContentPhase.Login -> LoginPhaseContent(state, callbacks)
}
}
}
}
@Composable
private fun ServerSearchContent(
state: LoginContentState,
callbacks: LoginContentCallbacks
) {
val scheme = MaterialTheme.colorScheme
Text(
text = "Find your server",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Search by address or choose a nearby Jellyfin server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp, bottom = 16.dp).fillMaxWidth()
)
Column(modifier = Modifier.testTag(LoginServerUrlFieldTag)) {
PurefinComplexTextField(
label = "Server URL",
value = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
}
Spacer(modifier = Modifier.height(16.dp))
PurefinTextButton(
content = { Text(if (state.isSearching) "Searching..." else "Find server") },
onClick = callbacks.onFindServer,
enabled = !state.isSearching,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginFindServerButtonTag)
.height(48.dp)
)
if (state.discoveredServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.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.forEachIndexed { index, server ->
TextButton(
onClick = { callbacks.onDiscoveredServerClick(server) },
modifier = Modifier
.fillMaxWidth()
.testTag("$LoginDiscoveredServerTagPrefix$index")
) {
Text(
text = server.name?.let { "$it\n${server.address}" } ?: server.address,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun LoginPhaseContent(
state: LoginContentState,
callbacks: LoginContentCallbacks
) {
val scheme = MaterialTheme.colorScheme
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
Text(
text = "Log in",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Text(
text = selectedServer,
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth()
)
TextButton(
onClick = callbacks.onChangeServer,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginChangeServerButtonTag)
) {
Text("Change server")
}
if (state.quickConnectAvailable) {
Spacer(modifier = Modifier.height(8.dp))
state.quickConnectCode?.let { code ->
Text(
text = code,
color = scheme.primary,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginQuickConnectCodeTag)
)
Text(
text = "Approve this code in another Jellyfin client.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(10.dp))
TextButton(
onClick = callbacks.onCancelQuickConnect,
modifier = Modifier.testTag(LoginQuickConnectCancelButtonTag)
) {
Text("Cancel Quick Connect")
}
} ?: PurefinTextButton(
content = { Text("Quick Connect") },
onClick = callbacks.onQuickConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginQuickConnectButtonTag)
.height(48.dp)
)
} else {
Text(
text = "Quick Connect is not enabled on this server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Manual login",
color = scheme.onBackground,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.testTag(LoginUsernameFieldTag)) {
PurefinComplexTextField(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
}
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.testTag(LoginPasswordFieldTag)) {
PurefinPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "Enter your password",
leadingIcon = Icons.Default.Lock,
)
}
Spacer(modifier = Modifier.height(24.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = callbacks.onConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier
.fillMaxWidth()
.testTag(LoginConnectButtonTag)
.height(48.dp)
)
}
internal const val LoginContentTag = "login-content"
internal const val LoginFindServerButtonTag = "login-find-server-button"
internal const val LoginServerUrlFieldTag = "login-server-url-field"
internal const val LoginDiscoveredServerTagPrefix = "login-discovered-server-"
internal const val LoginChangeServerButtonTag = "login-change-server-button"
internal const val LoginUsernameFieldTag = "login-username-field"
internal const val LoginPasswordFieldTag = "login-password-field"
internal const val LoginQuickConnectButtonTag = "login-quick-connect-button"
internal const val LoginQuickConnectCodeTag = "login-quick-connect-code"
internal const val LoginQuickConnectCancelButtonTag = "login-quick-connect-cancel-button"
internal const val LoginConnectButtonTag = "login-connect-button"
internal const val LoginErrorMessageTag = "login-error-message"

View File

@@ -1,15 +1,14 @@
package hu.bbara.purefin.ui.screen.login package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.login.LoginViewModel import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -17,52 +16,83 @@ fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val serverUrl by viewModel.url.collectAsState() val serverUrl by viewModel.url.collectAsStateWithLifecycle()
val username by viewModel.username.collectAsState() val phase by viewModel.phase.collectAsStateWithLifecycle()
val password by viewModel.password.collectAsState() val selectedServer by viewModel.selectedServer.collectAsStateWithLifecycle()
val errorMessage by viewModel.errorMessage.collectAsState() val discoveredServers by viewModel.discoveredServers.collectAsStateWithLifecycle()
var isLoggingIn by remember { mutableStateOf(false) } 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 coroutineScope = rememberCoroutineScope()
val state = remember(serverUrl, username, password, errorMessage) { val state = remember(
phase,
serverUrl,
selectedServer,
discoveredServers,
username,
password,
isSearching,
isLoggingIn,
quickConnectAvailable,
quickConnectCode,
isQuickConnecting,
errorMessage
) {
LoginContentState( LoginContentState(
phase = if (phase == LoginViewModel.Phase.LOGIN) {
LoginContentPhase.Login
} else {
LoginContentPhase.ServerSearch
},
serverUrl = serverUrl, serverUrl = serverUrl,
selectedServerName = selectedServer?.name,
selectedServerUrl = selectedServer?.address,
discoveredServers = discoveredServers.map {
LoginServerCandidateUi(name = it.name, address = it.address)
},
username = username, username = username,
password = password, password = password,
isSearching = isSearching,
isLoggingIn = isLoggingIn,
quickConnectAvailable = quickConnectAvailable,
quickConnectCode = quickConnectCode,
isQuickConnecting = isQuickConnecting,
errorMessage = errorMessage errorMessage = errorMessage
) )
} }
val callbacks = remember(viewModel, coroutineScope) { val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks( LoginContentCallbacks(
onServerUrlChange = { onServerUrlChange = viewModel::setUrl,
viewModel.clearError() onFindServer = viewModel::findServer,
viewModel.setUrl(it) onDiscoveredServerClick = {
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
}, },
onChangeServer = viewModel::changeServer,
onUsernameChange = { onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it) viewModel.setUsername(it)
}, },
onPasswordChange = { onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it) viewModel.setPassword(it)
}, },
onConnect = { onConnect = {
coroutineScope.launch { coroutineScope.launch {
isLoggingIn = true viewModel.login()
try {
viewModel.login()
} finally {
isLoggingIn = false
}
} }
} },
onQuickConnect = viewModel::startQuickConnect,
onCancelQuickConnect = viewModel::cancelQuickConnect
) )
} }
LoginContent( LoginContent(
state = state, state = state,
callbacks = callbacks, callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier modifier = modifier
) )
} }

View File

@@ -1,47 +1,38 @@
package hu.bbara.purefin.ui.screen.movie package hu.bbara.purefin.ui.screen.movie
import android.Manifest import android.content.Intent
import android.os.Build import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.download.DownloadState import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.image.ArtworkKind import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaHero import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.screen.movie.components.MovieWatchedButtonTag
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -49,27 +40,23 @@ import java.util.UUID
@Composable @Composable
fun MovieScreen( fun MovieScreen(
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier movie: MovieDto,
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) { ) {
LaunchedEffect(movie.id) { LaunchedEffect(movie) {
viewModel.selectMovie(movie.id) viewModel.selectMovie(movie)
} }
val movieItem = viewModel.movie.collectAsState() val movieItem = viewModel.movie.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsState() val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val requestNotificationPermission = rememberNotificationPermissionGate()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val onDownloadClick = { val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU if (downloadState.value is DownloadState.NotDownloaded) {
&& downloadState.value is DownloadState.NotDownloaded requestNotificationPermission {
) { viewModel.onDownloadClick()
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else { } else {
viewModel.onDownloadClick() viewModel.onDownloadClick()
} }
@@ -81,6 +68,7 @@ fun MovieScreen(
downloadState = downloadState.value, downloadState = downloadState.value,
onDownloadClick = onDownloadClick, onDownloadClick = onDownloadClick,
onBack = viewModel::onBack, onBack = viewModel::onBack,
onMarkAsWatched = viewModel::markAsWatched,
modifier = modifier modifier = modifier
) )
} else { } else {
@@ -88,102 +76,87 @@ fun MovieScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MovieScreenInternal( private fun MovieScreenInternal(
movie: Movie, movie: Movie,
downloadState: DownloadState = DownloadState.NotDownloaded, downloadState: DownloadState = DownloadState.NotDownloaded,
onDownloadClick: () -> Unit = {}, onDownloadClick: () -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive() val context = LocalContext.current
val playAction = remember(movie.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movie.id.toString())
context.startActivity(intent)
}
}
Scaffold( MediaDetailScaffold(
uiModel = movie.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
onMarkAsWatched = onMarkAsWatched,
),
modifier = modifier, modifier = modifier,
containerColor = MaterialTheme.colorScheme.background, topBar = { scrollBehavior ->
topBar = { MovieTopBar(
if (!isHomeMediaTransitionActive) { onBack = onBack,
MovieTopBar( scrollBehavior = scrollBehavior
onBack = onBack, )
modifier = Modifier },
) )
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
MediaHeroSection(movie = movie)
if (!isHomeMediaTransitionActive) {
MovieDetails(
movie = movie,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = innerPadding.calculateBottomPadding())
)
}
}
}
} }
@Composable private fun Movie.toMediaDetailScaffoldUiModel(
fun MediaHeroSection( onPlayClick: () -> Unit,
movie: Movie, downloadState: DownloadState,
modifier: Modifier = Modifier) { onDownloadClick: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit,
val scheme = MaterialTheme.colorScheme ): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
val screenHeight = LocalConfiguration.current.screenHeightDp.dp imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
val sectionHeight = screenHeight * 0.4f title = title,
metadataItems = listOf(year, rating, runtime, format),
Box ( highlightedMetadataItem = format,
modifier = modifier actions = MediaDetailActionsUiModel(
.homeMediaSharedBoundsDestination() primaryAction = MediaDetailPrimaryActionUiModel(
.height(sectionHeight) text = mediaPlayButtonText(progress, watched),
) { progress = mediaPlaybackProgress(progress),
MediaHero( onClick = onPlayClick,
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY), testTag = MoviePlayButtonTag
backgroundColor = MaterialTheme.colorScheme.background, ),
modifier = Modifier.fillMaxSize() secondaryActions = listOf(
) MediaDetailSecondaryActionUiModel.MarkAsWatched(
Box( watched = watched,
modifier = Modifier onClick = { onMarkAsWatched(!watched) },
.matchParentSize() testTag = MovieWatchedButtonTag
.background( ),
Brush.verticalGradient( MediaDetailSecondaryActionUiModel.Download(
colors = listOf( downloadState = downloadState,
Color.Transparent, onClick = onDownloadClick,
Color.Transparent, testTag = MovieDownloadButtonTag
scheme.background.copy(alpha = 0.5f),
scheme.background
)
)
)
)
Column (
modifier = Modifier.fillMaxWidth()
.align(Alignment.BottomStart)
.padding(16.dp)
) {
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp
) )
Spacer(modifier = Modifier.height(16.dp)) ),
MediaMetadataFlowRow( dividerThickness = 4.dp
items = listOf(movie.year, movie.rating, movie.runtime, movie.format), ),
highlightedItem = movie.format synopsis = MediaDetailSynopsisUiModel(text = synopsis),
) playbackSettings = MediaDetailPlaybackSettingsUiModel(
} audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
members = cast,
topSpacing = 24.dp
)
} else {
null
} }
} )
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable

View File

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

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.provider.Settings
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -11,8 +12,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -21,11 +25,11 @@ import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -34,25 +38,40 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import hu.bbara.purefin.player.viewmodel.PlayerViewModel import hu.bbara.purefin.core.player.model.TimedMarker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
@@ -60,33 +79,121 @@ fun PlayerScreen(
viewModel: PlayerViewModel, viewModel: PlayerViewModel,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsState() val scope = rememberCoroutineScope()
val controlsVisible by viewModel.controlsVisible.collectAsState() var hideControlsJob: Job? by remember { mutableStateOf(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var controlsVisible by remember { mutableStateOf(true) }
var controlsHasPopupOpen by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity val activity = context as? Activity
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) } val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) } var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) } var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
var isAutoBrightness by remember { mutableStateOf(false) }
var dragOvershoot by remember { mutableFloatStateOf(0f) }
var autoDragTick by remember { mutableStateOf(0) }
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
var showQueuePanel by remember { mutableStateOf(false) } var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) } var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
val overlayController = rememberPersistentOverlayController() val overlayController = rememberPersistentOverlayController()
LaunchedEffect(uiState.isPlaying) {
if (uiState.isPlaying) {
showQueuePanel = false
}
}
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction = val subtitleBottomPaddingFraction =
if (playerControlsVisible) { if (controlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else { } else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
} }
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null && uiState.activeSkippableSegmentType == SegmentType.INTRO
val showNextEpisodeOverlay = !controlsVisible
&& uiState.nextEpisode != null
&& uiState.durationMs > 0L
&& ((uiState.durationMs - uiState.positionMs) <= 60_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
&& !uiState.isEnded
fun toggleControlsVisibility() {
controlsVisible = !controlsVisible
}
fun hideControls() {
controlsVisible = false
}
fun hideControlsWithTimeout() {
hideControlsJob?.cancel()
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
hideControlsJob = scope.launch {
delay(CONTROLS_AUTO_HIDE_MS)
hideControls()
}
}
}
fun onScreenTap() {
toggleControlsVisibility()
hideControlsWithTimeout()
}
fun onBrightnessDragStart(isLeftSide: Boolean) {
if (isLeftSide) {
dragOvershoot = 0f
}
}
fun onBrightnessDragEnd() {
dragOvershoot = 0f
}
fun onBrightnessDrag(delta: Float) {
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
if (isAutoBrightness) {
if (diff > 0f) {
dragOvershoot += diff
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
isAutoBrightness = false
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
} else {
autoDragTick++
}
} else {
val newBrightness = brightness + diff
if (newBrightness <= 0f) {
brightness = 0f
applyBrightness(activity, brightness, isAuto = false)
dragOvershoot += newBrightness
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
isAutoBrightness = true
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = true)
}
} else {
brightness = newBrightness.coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
}
}
fun onVolumeDrag(delta: Float) {
val diff = -delta / 800f
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
restoreBrightness(activity, originalScreenBrightness)
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -105,6 +212,9 @@ fun PlayerScreen(
it.player = viewModel.player it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction) it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
}, },
onRelease = { playerView ->
playerView.player = null
},
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.align(Alignment.Center) .align(Alignment.Center)
@@ -112,57 +222,78 @@ fun PlayerScreen(
PlayerGesturesLayer( PlayerGesturesLayer(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
onTap = { viewModel.toggleControlsVisibility() }, controlsVisible = controlsVisible,
onTap = ::onScreenTap,
onDoubleTapRight = { viewModel.seekBy(30_000) }, onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) }, onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() }, onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta -> onVerticalDragStart = ::onBrightnessDragStart,
val diff = (-delta / 800f) onVerticalDragEnd = ::onBrightnessDragEnd,
brightness = (brightness + diff).coerceIn(0f, 1f) onVerticalDragLeft = ::onBrightnessDrag,
applyBrightness(activity, brightness) onVerticalDragRight = ::onVolumeDrag,
}, onHorizontalDragPreview = { deltaMs, previewPositionMs ->
onVerticalDragRight = { delta -> horizontalSeekFeedback = deltaMs
val diff = (-delta / 800f) horizontalSeekPreviewPositionMs = previewPositionMs?.let {
volume = (volume + diff).coerceIn(0f, 1f) if (uiState.durationMs > 0L) {
audioManager.setStreamVolume( it.coerceIn(0L, uiState.durationMs)
AudioManager.STREAM_MUSIC, } else {
(volume * maxVolume).roundToInt(), it.coerceAtLeast(0L)
0 }
) }
},
onHorizontalDragPreview = {
horizontalSeekFeedback = it
}, },
onHorizontalDragSeekTo = { onHorizontalDragSeekTo = {
viewModel.seekTo(it) viewModel.seekTo(it)
}, },
currentPositionProvider = { uiState.positionMs } currentPositionProvider = { uiState.positionMs },
) )
EmptyValueTimedVisibility( EmptyValueTimedVisibility(
value = horizontalSeekFeedback, value = horizontalSeekFeedback,
hideAfterMillis = 1_000 hideAfterMillis = 1_000,
modifier = Modifier
.align(Alignment.Center)
) { ) {
SeekAmountIndicator( SeekAmountIndicator(
deltaMs = it, deltaMs = it,
modifier = Modifier
.align(Alignment.Center)
) )
} }
EmptyValueTimedVisibility(
value = horizontalSeekPreviewPositionMs,
hideAfterMillis = 1_000,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 24.dp, vertical = 28.dp)
) { previewPositionMs ->
if (!controlsVisible) {
HiddenSeekTimeline(
positionMs = previewPositionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads,
isLive = uiState.isLive
)
}
}
ValueChangeTimedVisibility( ValueChangeTimedVisibility(
value = brightness, value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
hideAfterMillis = 800, hideAfterMillis = 800,
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.padding(start = 20.dp) .padding(start = 20.dp)
) { currentBrightness -> ) { state ->
PlayerAdjustmentIndicator( PlayerAdjustmentIndicator(
modifier = Modifier modifier = Modifier.align(Alignment.Center),
.align(Alignment.Center),
icon = Icons.Outlined.BrightnessMedium, icon = Icons.Outlined.BrightnessMedium,
contentDescription = "Brightness", contentDescription = "Brightness",
value = currentBrightness value = state.brightness,
bottomText = if (state.isAuto) {
"Auto"
} else {
"${(state.brightness * 100).roundToInt()}%"
}
) )
} }
@@ -178,19 +309,19 @@ fun PlayerScreen(
.align(Alignment.Center), .align(Alignment.Center),
icon = Icons.Outlined.VolumeUp, icon = Icons.Outlined.VolumeUp,
contentDescription = "Volume", contentDescription = "Volume",
value = currentVolume value = currentVolume,
bottomText = "${(currentVolume.coerceIn(0f, 1f) * 100).roundToInt()}%"
) )
} }
AnimatedVisibility( AnimatedVisibility(
visible = playerControlsVisible, visible = controlsVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
PlayerControlsOverlay( PlayerControlsOverlay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
uiState = uiState, uiState = uiState,
showControls = controlsVisible,
overlayController = overlayController, overlayController = overlayController,
onBack = onBack, onBack = onBack,
onPlayPause = { viewModel.togglePlayPause() }, onPlayPause = { viewModel.togglePlayPause() },
@@ -201,20 +332,45 @@ fun PlayerScreen(
onNext = { viewModel.next() }, onNext = { viewModel.next() },
onPrevious = { viewModel.previous() }, onPrevious = { viewModel.previous() },
onSelectTrack = { viewModel.selectTrack(it) }, onSelectTrack = { viewModel.selectTrack(it) },
onQueueSelected = { viewModel.playQueueItem(it) },
onOpenQueue = { showQueuePanel = true } onOpenQueue = { showQueuePanel = true }
) )
} }
AnimatedVisibility( AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null, visible = showSkipIntroButton,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(
end = 24.dp,
)
) {
SkipSegmentButton(
size = 72.dp,
fontSize = 22,
onClick = { viewModel.skipActiveSegment() }
)
}
AnimatedVisibility(
visible = showNextEpisodeOverlay,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp) .padding(
end = 24.dp,
bottom = 24.dp
)
) { ) {
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() }) uiState.nextEpisode?.let { nextEpisode ->
NextEpisodeOverlay(
nextEpisode = nextEpisode,
onClick = { viewModel.next() }
)
}
} }
PlayerLoadingErrorEndCard( PlayerLoadingErrorEndCard(
@@ -252,6 +408,48 @@ fun PlayerScreen(
} }
} }
@Composable
private fun HiddenSeekTimeline(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
isLive: Boolean,
modifier: Modifier = Modifier
) {
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Transparent)
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
PlayerTimeRow(
positionMs = positionMs,
durationMs = durationMs,
isLive = isLive,
onSeekLiveEdge = null
)
PlayerSeekBarTrack(
positionMs = positionMs,
durationMs = safeDuration,
bufferedMs = bufferedMs,
chapterMarkers = chapterMarkers,
adMarkers = adMarkers,
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
isFocused = false,
focusedTrackHeight = 4.dp,
focusedThumbRadius = 6.dp,
focusedThumbHaloRadiusDelta = 0.dp
)
}
}
@Composable @Composable
private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) { private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -282,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
} }
} }
private fun readCurrentBrightness(activity: Activity?): Float { private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else 0.5f private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
val windowBrightness = activity?.window?.attributes?.screenBrightness
if (windowBrightness != null && windowBrightness >= 0f) {
return windowBrightness
}
return try {
val systemBrightness = Settings.System.getInt(
context.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
(systemBrightness / 255f).coerceIn(0f, 1f)
} catch (e: Settings.SettingNotFoundException) {
0.5f
}
} }
private fun applyBrightness(activity: Activity?, value: Float) { private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
activity ?: return activity ?: return
val params = activity.window.attributes val params = activity.window.attributes
params.screenBrightness = value params.screenBrightness = if (isAuto) -1f else brightness
activity.window.attributes = params
}
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = originalValue
activity.window.attributes = params activity.window.attributes = params
} }

View File

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

View File

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

View File

@@ -10,11 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast import androidx.compose.material.icons.outlined.Cast
@@ -26,31 +22,26 @@ import androidx.compose.material.icons.outlined.PlaylistPlay
import androidx.compose.material.icons.outlined.Replay10 import androidx.compose.material.icons.outlined.Replay10
import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.ui.common.button.CircularTextButton
import hu.bbara.purefin.ui.common.button.GhostIconButton import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.PurefinIconButton import hu.bbara.purefin.ui.common.button.PurefinIconButton
@Composable @Composable
fun PlayerControlsOverlay( fun PlayerControlsOverlay(
uiState: PlayerUiState, uiState: PlayerUiState,
showControls: Boolean,
overlayController: PersistentOverlayController, overlayController: PersistentOverlayController,
onBack: () -> Unit, onBack: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
@@ -61,16 +52,14 @@ fun PlayerControlsOverlay(
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onSelectTrack: (TrackOption) -> Unit, onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
onOpenQueue: () -> Unit, onOpenQueue: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme
var scrubbing by remember { mutableStateOf(false) }
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.testTag(PlayerControlsOverlayTag)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf( listOf(
@@ -97,11 +86,8 @@ fun PlayerControlsOverlay(
) )
BottomSection( BottomSection(
uiState = uiState, uiState = uiState,
scrubbing = scrubbing,
overlayController = overlayController, overlayController = overlayController,
onScrubStart = { scrubbing = true },
onScrub = onSeek, onScrub = onSeek,
onScrubFinished = { scrubbing = false },
onNext = onNext, onNext = onNext,
onPrevious = onPrevious, onPrevious = onPrevious,
onPlayPause = onPlayPause, onPlayPause = onPlayPause,
@@ -110,7 +96,6 @@ fun PlayerControlsOverlay(
onSeekLiveEdge = onSeekLiveEdge, onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment, onSkipSegment = onSkipSegment,
onSelectTrack = onSelectTrack, onSelectTrack = onSelectTrack,
onQueueSelected = onQueueSelected,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
} }
@@ -150,6 +135,7 @@ private fun TopBar(
GhostIconButton( GhostIconButton(
icon = Icons.Outlined.PlaylistPlay, icon = Icons.Outlined.PlaylistPlay,
contentDescription = "Queue", contentDescription = "Queue",
modifier = Modifier.testTag(PlayerQueueButtonTag),
onClick = onOpenQueue onClick = onOpenQueue
) )
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = onCast) GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = onCast)
@@ -161,11 +147,8 @@ private fun TopBar(
@Composable @Composable
private fun BottomSection( private fun BottomSection(
uiState: PlayerUiState, uiState: PlayerUiState,
scrubbing: Boolean,
overlayController: PersistentOverlayController, overlayController: PersistentOverlayController,
onScrubStart: () -> Unit,
onScrub: (Long) -> Unit, onScrub: (Long) -> Unit,
onScrubFinished: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
@@ -174,43 +157,16 @@ private fun BottomSection(
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit, onSkipSegment: () -> Unit,
onSelectTrack: (TrackOption) -> Unit, onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) { Column(modifier = modifier) {
Row( PlayerTimeRow(
modifier = Modifier positionMs = uiState.positionMs,
.fillMaxWidth() durationMs = uiState.durationMs,
.padding(horizontal = 8.dp), isLive = uiState.isLive,
horizontalArrangement = Arrangement.SpaceBetween, onSeekLiveEdge = onSeekLiveEdge,
verticalAlignment = Alignment.CenterVertically modifier = Modifier.padding(horizontal = 8.dp)
) { )
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodySmall
)
if (uiState.isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.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.bodySmall
)
}
}
PlayerSeekBar( PlayerSeekBar(
positionMs = uiState.positionMs, positionMs = uiState.positionMs,
durationMs = uiState.durationMs, durationMs = uiState.durationMs,
@@ -218,8 +174,7 @@ private fun BottomSection(
chapterMarkers = uiState.chapters, chapterMarkers = uiState.chapters,
adMarkers = uiState.ads, adMarkers = uiState.ads,
onSeek = onScrub, onSeek = onScrub,
onScrubStarted = onScrubStart, modifier = Modifier.testTag(PlayerSeekBarTag)
onScrubFinished = onScrubFinished
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Box( Box(
@@ -236,30 +191,35 @@ private fun BottomSection(
icon = Icons.Outlined.SkipPrevious, icon = Icons.Outlined.SkipPrevious,
contentDescription = "Previous", contentDescription = "Previous",
onClick = onPrevious, onClick = onPrevious,
modifier = Modifier.testTag(PlayerPreviousButtonTag),
size = 64 size = 64
) )
PurefinIconButton( PurefinIconButton(
icon = Icons.Outlined.Replay10, icon = Icons.Outlined.Replay10,
contentDescription = "Seek backward", contentDescription = "Seek backward",
onClick = onSeekBackward, onClick = onSeekBackward,
modifier = Modifier.testTag(PlayerSeekBackwardButtonTag),
size = 64 size = 64
) )
PurefinIconButton( PurefinIconButton(
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
contentDescription = "Play/Pause", contentDescription = "Play/Pause",
onClick = onPlayPause, onClick = onPlayPause,
modifier = Modifier.testTag(PlayerPlayPauseButtonTag),
size = 64 size = 64
) )
PurefinIconButton( PurefinIconButton(
icon = Icons.Outlined.Forward30, icon = Icons.Outlined.Forward30,
contentDescription = "Seek forward", contentDescription = "Seek forward",
onClick = onSeekForward, onClick = onSeekForward,
modifier = Modifier.testTag(PlayerSeekForwardButtonTag),
size = 64 size = 64
) )
PurefinIconButton( PurefinIconButton(
icon = Icons.Outlined.SkipNext, icon = Icons.Outlined.SkipNext,
contentDescription = "Next", contentDescription = "Next",
onClick = onNext, onClick = onNext,
modifier = Modifier.testTag(PlayerNextButtonTag),
size = 64 size = 64
) )
} }
@@ -290,7 +250,9 @@ private fun BottomSection(
if (uiState.activeSkippableSegmentEndMs != null) { if (uiState.activeSkippableSegmentEndMs != null) {
SkipSegmentButton( SkipSegmentButton(
onClick = onSkipSegment, onClick = onSkipSegment,
modifier = Modifier.align(Alignment.CenterEnd) modifier = Modifier
.align(Alignment.CenterEnd)
.testTag(PlayerSkipSegmentButtonTag)
) )
} }
} }
@@ -298,34 +260,77 @@ private fun BottomSection(
} }
} }
internal const val PlayerControlsOverlayTag = "player-controls-overlay"
internal const val PlayerQueueButtonTag = "player_queue_button"
internal const val PlayerSeekBarTag = "player_seek_bar"
internal const val PlayerPreviousButtonTag = "player_previous_button"
internal const val PlayerSeekBackwardButtonTag = "player_seek_backward_button"
internal const val PlayerPlayPauseButtonTag = "player_play_pause_button"
internal const val PlayerSeekForwardButtonTag = "player_seek_forward_button"
internal const val PlayerNextButtonTag = "player_next_button"
internal const val PlayerSkipSegmentButtonTag = "player_skip_segment_button"
@Composable @Composable
fun SkipSegmentButton( fun SkipSegmentButton(
onClick: () -> Unit, onClick: () -> Unit,
fontSize: Int = 18,
size: Dp = 52.dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
FilledTonalButton( CircularTextButton(
text = "Skip",
textColor = scheme.onSecondary,
fontSize = fontSize,
containerColor = scheme.secondary,
size = size,
onClick = onClick, onClick = onClick,
modifier = modifier.heightIn(min = 44.dp), modifier = modifier
shape = RoundedCornerShape(50), )
colors = ButtonDefaults.filledTonalButtonColors( }
containerColor = scheme.secondary.copy(alpha = 0.92f),
contentColor = scheme.onSecondary @Composable
), internal fun PlayerTimeRow(
contentPadding = ButtonDefaults.ButtonWithIconContentPadding positionMs: Long,
durationMs: Long,
isLive: Boolean,
onSeekLiveEdge: (() -> Unit)?,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Skip", text = formatTime(positionMs),
style = MaterialTheme.typography.labelLarge, color = scheme.onSurface,
fontWeight = FontWeight.Bold style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Outlined.SkipNext,
contentDescription = null,
modifier = Modifier.size(18.dp)
) )
if (isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold)
if (onSeekLiveEdge != null) {
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier.clickable { onSeekLiveEdge() }
)
}
}
} else {
Text(
text = formatTime(durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodySmall
)
}
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -22,13 +23,16 @@ import kotlin.math.abs
@Composable @Composable
fun PlayerGesturesLayer( fun PlayerGesturesLayer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controlsVisible: Boolean,
onTap: () -> Unit, onTap: () -> Unit,
onDoubleTapCenter: () -> Unit, onDoubleTapCenter: () -> Unit,
onDoubleTapRight: () -> Unit, onDoubleTapRight: () -> Unit,
onDoubleTapLeft: () -> Unit, onDoubleTapLeft: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit, onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit, onVerticalDragRight: (delta: Float) -> Unit,
onHorizontalDragPreview: (deltaMs: Long?) -> Unit = {}, onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
onVerticalDragEnd: () -> Unit = {},
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
onHorizontalDragSeekTo: (positionMs: Long) -> Unit, onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
currentPositionProvider: () -> Long, currentPositionProvider: () -> Long,
) { ) {
@@ -36,6 +40,7 @@ fun PlayerGesturesLayer(
val horizontalThresholdPx = with(density) { HorizontalSeekGestureHelper.START_THRESHOLD.toPx() } val horizontalThresholdPx = with(density) { HorizontalSeekGestureHelper.START_THRESHOLD.toPx() }
val directionThresholdPx = with(density) { 20.dp.toPx() } val directionThresholdPx = with(density) { 20.dp.toPx() }
val dragActive = remember { mutableStateOf(false) } val dragActive = remember { mutableStateOf(false) }
val currentControlsVisible = rememberUpdatedState(controlsVisible)
Box( Box(
modifier = modifier modifier = modifier
@@ -63,12 +68,20 @@ fun PlayerGesturesLayer(
val startX = down.position.x val startX = down.position.x
dragActive.value = false dragActive.value = false
if (currentControlsVisible.value) {
// Swipe gestures are disabled while the controls overlay is visible.
// Tap gestures (single/double tap) are still handled by the
// pointerInput block above.
return@awaitEachGesture
}
var accumulatedDrag = Offset.Zero var accumulatedDrag = Offset.Zero
var dragDirection: DragDirection? = null var dragDirection: DragDirection? = null
var accumulatedHorizontalDrag = 0f var accumulatedHorizontalDrag = 0f
var isHorizontalDragActive = false var isHorizontalDragActive = false
var lastPreviewDelta: Long? = null var lastPreviewDelta: Long? = null
var startPositionMs = 0L var startPositionMs = 0L
var verticalDragStarted = false
drag(down.id) { change -> drag(down.id) { change ->
val delta = change.positionChange() val delta = change.positionChange()
@@ -95,13 +108,20 @@ fun PlayerGesturesLayer(
val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag) val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag)
if (deltaMs != 0L && deltaMs != lastPreviewDelta) { if (deltaMs != 0L && deltaMs != lastPreviewDelta) {
lastPreviewDelta = deltaMs lastPreviewDelta = deltaMs
onHorizontalDragPreview(deltaMs) onHorizontalDragPreview(
deltaMs,
(startPositionMs + deltaMs).coerceAtLeast(0L)
)
} }
} }
} }
DragDirection.VERTICAL -> { DragDirection.VERTICAL -> {
change.consume() change.consume()
val isLeftSide = startX < size.width / 2 val isLeftSide = startX < size.width / 2
if (!verticalDragStarted) {
verticalDragStarted = true
onVerticalDragStart(isLeftSide)
}
if (isLeftSide) { if (isLeftSide) {
onVerticalDragLeft(delta.y) onVerticalDragLeft(delta.y)
} else { } else {
@@ -117,10 +137,14 @@ fun PlayerGesturesLayer(
if (deltaMs != 0L) { if (deltaMs != 0L) {
val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L) val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L)
onHorizontalDragSeekTo(targetMs) onHorizontalDragSeekTo(targetMs)
onHorizontalDragPreview(deltaMs) onHorizontalDragPreview(deltaMs, targetMs)
} }
} }
onHorizontalDragPreview(null) onHorizontalDragPreview(null, null)
if (verticalDragStarted) {
onVerticalDragEnd()
}
} }
} }
) )

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable @Composable
fun PlayerLoadingErrorEndCard( fun PlayerLoadingErrorEndCard(

View File

@@ -26,10 +26,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable @Composable
@@ -40,7 +41,12 @@ fun PlayerQueuePanel(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Box(
modifier = modifier
.fillMaxWidth()
.testTag(PlayerPlaylistPanelTag),
contentAlignment = Alignment.CenterEnd
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
@@ -78,13 +84,23 @@ fun PlayerQueuePanel(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.testTag(PlayerPlaylistRowTag)
) {
items(uiState.queue, key = { item -> item.id }) { item -> items(uiState.queue, key = { item -> item.id }) { item ->
QueueRow( QueueRow(
title = item.title, title = item.title,
artworkUrl = item.artworkUrl, artworkUrl = item.artworkUrl,
isCurrent = item.isCurrent, isCurrent = item.isCurrent,
onClick = { onSelect(item.id) } onClick = { onSelect(item.id) },
modifier = Modifier.testTag(
if (item.isCurrent) {
PlayerPlaylistCurrentItemTag
} else {
"${PlayerPlaylistItemTagPrefix}${item.id}"
}
)
) )
} }
} }
@@ -106,11 +122,12 @@ private fun QueueRow(
subtitle: String? = null, subtitle: String? = null,
artworkUrl: String?, artworkUrl: String?,
isCurrent: Boolean, isCurrent: Boolean,
onClick: () -> Unit onClick: () -> Unit,
modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .background(
@@ -160,3 +177,8 @@ private fun QueueRow(
} }
} }
} }
internal const val PlayerPlaylistPanelTag = "player_playlist_panel"
internal const val PlayerPlaylistRowTag = "player_playlist_row"
internal const val PlayerPlaylistCurrentItemTag = "player_playlist_current_item"
internal const val PlayerPlaylistItemTagPrefix = "player_playlist_item_"

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