From 7171ec72c19be160cdd18beb84ace136c2854126 Mon Sep 17 00:00:00 2001 From: Jarne Demeulemeester Date: Sat, 30 Dec 2023 22:20:20 +0100 Subject: [PATCH 01/29] feat: android tv (#598) * Add AddServerScreen * Upgrade androidx-compose-material3 and androidx-compose-ui to alpha * Add DiscoveredServerComponent * Show discovered servers * Add navigation using compose-destinations * Implement Loginscreen * Start of HomeScreen * Use coil for home screen images and update layout with spacers * Select correct startRoute based on conditions * Upgrade compose material3 to 1.1.0-alpha05 * Add series title, max 1 line, padding * Upgrade dependencies * Switch to TvLazyColumn and TvLazyRow * Add header to `HomeScreen` * Add progress bar to Continue watching items * Limit the number of lines under Movie or Show and use correct episode text * chore: run ktlintFormat * ci: assemble tv * feat: `LibraryScreen` * fix: update to reworked items system * chore(deps): update androidx-paging-compose and compose-destination Also fix lint issue on HomeScreen * feat: start using androidx.material3 composables * feat: add coil svg * feat: experimenting with cards * lint: fix linting issues * feat: server select screen * build: upgrade dependencies * lint: run ktlintFormat * feat(ServerSelectScreen): add "No servers found" text * feat: update AddServerScreen * feat: implement `UiText.asString()` composable * lint: run ktlintFormat * refactor(phone): remove livedata from `ServerSelectScreen` * feat: add `UserSelectScreen` * feat(UserSelectScreen): load user's profile picture * feat: update LoginScreen * feat: update progress indicator on `AddServerScreen` * fix: change color of `ServerComponent` * style(ServerSelect): use material typography * chore: update ktlint config in build.gradle * style: use material typography * refactor: move home screen to separate layout function so it can be previewed - Introduce dummy items - Fix `UiText.asString()` composable * refactor: preview `LibraryScreen` * refactor: preview `ServerSelectScreen` * refactor: preview `AddServerScreen` * refactor: preview `UserSelectScreen` * refactor: preview `LoginScreen` * lint: run ktlintFormat * feat: switch servers * feat: main screen tabs navigation * feat: add Live TV tab (not shown for now) * chore: remove libraries from `HomeViewModel` * chore: change colors of selected tab * feat: new item card component * chore: remove `HomeItem.Libraries` * style: update spacing * feat: add findroid icon and profile button on main screen * style(main): add gradient background * fix: use "latest" + library name in home screen * fix: navigate from LoginScreen and UserSelectScreen to MainScreen * style: update tab colors * fix: remove reference to `HomeItem.Libraries` from `ViewListAdapter` * chore: update kotlin compiler to 1.5.0 * feat: add horizontal item card variant * feat: `LibrariesScreen` * feat: `LibraryScreen` * fix(`LibraryScreen`): remove hardcoded library name * feat: `MovieScreen` * feat(`MovieScreen`): Make trailer button work Also hide the button when there is no trailer * refactor(`MovieScreen`): move click logic out of layout * refactor: create `FindroidImages` which holds all image uris * build: upgrade androidx.tv to 1.0.0-alpha08 * chore: update compose libraries * chore: update compose compiler to 1.5.1 * fix(ItemCard): only show progress when horizontal * refactor: clean build.gradle.kts * build: up minSdk to 28 and targetSdk to 34 * refactor: use spacings to provide paddings (#443) * feat: add Spacings in MaterialTheme * style: use MaterialTheme.spacings in layouts Using sizes in Spacer & padding & PaddingValues to standardize it. * fix: linting issues and a few paddings * feat: use spacings in `LibraryScreen` * feat: use spacings in `MovieScreen` * fix: missing trailing comma * refactor: replace hardcoded spacer in ItemCard with spacing --------- Co-authored-by: Jarne Demeulemeester * build: migrate to ksp and upgrade compose compiler * build: upgrade compose libraries * fix: align with main codebase * chore: update agp to 8.1.3 * chore: update ksp and compose-destinations ksp 1.9.10-1.0.13 -> 1.9.20-1.0.14 compose-destinations 1.9.51 -> 1.9.54 * refactor(UserSelectViewModel): use channel for events * feat: basic video player First implementation of the video player. Uses the basic player view with no custom layout. Only media keys are passed to the PlayerView. * feat: show screen Still a work in progress * fix: make player background black * fix(player): keep screen on * feat: add border around focused tab * lint: run ktlintFormat * feat: focus improvements * feat: logo for main screen * fix: remember tab position * feat: add loading indicator to main screen And fix home and libraries screen list refresh on navigating back * feat: add seasons to show screen * feat: add season screen * feat: add progress badge * chore(deps) update dependencies android-plugin 8.1.3 -> 8.1.4 androidx-activity 1.8.0 -> 1.8.1 androidx-media3 1.1.1 -> 1.2.0 coil 2.4.0 -> 2.5.0 kotlinx-serialization 1.6.0 -> 1.6.1 * ci: upload tv artifacts and don't build universal apks * chore: get rid of deprecated android.defaults.buildfeatures.buildconfig * build: upgrade dependencies android-plugin 8.1.4 -> 8.2.0 androidx-room 2.6.0 -> 2.6.1 androidx-work 2.8.1 -> 2.9.0 jellyfin 1.4.5 -> 1.4.6 compose compiler 1.5.4 -> 1.5.5 * fix: workManagerConfiguration is now a property * feat: add profile picture to main screen * feat: start of settings screen * refactor: base tv theme on normal compose material theme * chore(deps): update kotlin to 1.9.21 kotlin 1.9.20 -> 1.9.21 ksp 1.9.20-1.0.14 -> 1.9.21-1.0.15 compose-compiler 1.5.5 -> 1.5.6 * feat(settings): add categories and pop backstack when navigating to main screen * feat(settings): nested settings and switch setting * feat(settings): settings select component * feat(settings): icons for cache and about * feat(settings): add option to toggle mpv player * feat(settings): move preference value logic to viewmodel * feat(settings): add dependencies * chore: update compose compiler compose-compiler 1.5.6 -> 1.5.7 * feat(settings): add settings detail select card New sub settings screen with different layout Settings detail select card to select an option * feat: play episode from home screen * feat(player): basic custom overlay Courtesy of Android TV JetStreamCompose sample * feat(player): add track selection dialog * feat(player): add media session and clean up dpad events * refactor(mpv): implement track selection via TrackSelectionParameters Need to add ability to disable track type * feat: implement watched and favorite buttons * refactor: remove unused PreferenceType enum --------- --- .github/workflows/build.yaml | 25 +- app/phone/build.gradle.kts | 2 +- .../dev/jdtech/jellyfin/BaseApplication.kt | 4 +- .../jellyfin/adapters/ViewListAdapter.kt | 1 - .../fragments/EpisodeBottomSheetFragment.kt | 31 +- .../jellyfin/fragments/MovieFragment.kt | 33 +- .../jellyfin/fragments/SeasonFragment.kt | 22 - .../jdtech/jellyfin/fragments/ShowFragment.kt | 33 +- app/tv/build.gradle.kts | 112 +++++ app/tv/proguard-rules.pro | 30 ++ app/tv/src/main/AndroidManifest.xml | 38 ++ .../dev/jdtech/jellyfin/BaseApplication.kt | 18 + .../java/dev/jdtech/jellyfin/MainActivity.kt | 90 ++++ .../dev/jdtech/jellyfin/PlayerActivity.kt | 52 +++ .../dev/jdtech/jellyfin/ui/AddServerScreen.kt | 194 ++++++++ .../java/dev/jdtech/jellyfin/ui/HomeScreen.kt | 212 +++++++++ .../dev/jdtech/jellyfin/ui/LibrariesScreen.kt | 118 +++++ .../dev/jdtech/jellyfin/ui/LibraryScreen.kt | 143 ++++++ .../dev/jdtech/jellyfin/ui/LoginScreen.kt | 287 ++++++++++++ .../java/dev/jdtech/jellyfin/ui/MainScreen.kt | 210 +++++++++ .../dev/jdtech/jellyfin/ui/MovieScreen.kt | 375 ++++++++++++++++ .../dev/jdtech/jellyfin/ui/PlayerScreen.kt | 297 ++++++++++++ .../dev/jdtech/jellyfin/ui/SeasonScreen.kt | 170 +++++++ .../jdtech/jellyfin/ui/ServerSelectScreen.kt | 350 +++++++++++++++ .../dev/jdtech/jellyfin/ui/SettingsScreen.kt | 169 +++++++ .../jdtech/jellyfin/ui/SettingsSubScreen.kt | 249 ++++++++++ .../java/dev/jdtech/jellyfin/ui/ShowScreen.kt | 424 ++++++++++++++++++ .../jdtech/jellyfin/ui/UserSelectScreen.kt | 288 ++++++++++++ .../jdtech/jellyfin/ui/components/Banner.kt | 24 + .../jellyfin/ui/components/EpisodeCard.kt | 113 +++++ .../jdtech/jellyfin/ui/components/ItemCard.kt | 173 +++++++ .../jellyfin/ui/components/ItemPoster.kt | 51 +++ .../ui/components/LoadingIndicator.kt | 18 + .../ui/components/PillBorderIndicator.kt | 74 +++ .../jellyfin/ui/components/ProfileButton.kt | 96 ++++ .../jellyfin/ui/components/ProgressBadge.kt | 94 ++++ .../ui/components/SettingsCategoryCard.kt | 110 +++++ .../components/SettingsDetailsSelectCard.kt | 118 +++++ .../ui/components/SettingsSelectCard.kt | 124 +++++ .../ui/components/SettingsSwitchCard.kt | 159 +++++++ .../components/player/VideoPlayerControls.kt | 82 ++++ .../player/VideoPlayerMediaButton.kt | 34 ++ .../player/VideoPlayerMediaTitle.kt | 43 ++ .../components/player/VideoPlayerOverlay.kt | 102 +++++ .../components/player/VideoPlayerSeekBar.kt | 133 ++++++ .../ui/components/player/VideoPlayerSeeker.kt | 120 +++++ .../ui/components/player/VideoPlayerState.kt | 45 ++ .../jellyfin/ui/dialogs/BaseDialogStyle.kt | 12 + .../dialogs/VideoPlayerTrackSelectorDialog.kt | 120 +++++ .../jdtech/jellyfin/ui/dummy/Collections.kt | 25 ++ .../dev/jdtech/jellyfin/ui/dummy/Episodes.kt | 66 +++ .../dev/jdtech/jellyfin/ui/dummy/HomeItems.kt | 26 ++ .../dev/jdtech/jellyfin/ui/dummy/Movies.kt | 62 +++ .../dev/jdtech/jellyfin/ui/dummy/Servers.kt | 22 + .../java/dev/jdtech/jellyfin/ui/dummy/Show.kt | 30 ++ .../dev/jdtech/jellyfin/ui/dummy/Users.kt | 12 + .../dev/jdtech/jellyfin/ui/theme/Color.kt | 34 ++ .../dev/jdtech/jellyfin/ui/theme/Shape.kt | 18 + .../dev/jdtech/jellyfin/ui/theme/Spacing.kt | 25 ++ .../dev/jdtech/jellyfin/ui/theme/Theme.kt | 30 ++ .../java/dev/jdtech/jellyfin/ui/theme/Type.kt | 45 ++ app/tv/src/main/res/drawable/ic_banner.xml | 76 ++++ app/tv/src/main/res/values/themes.xml | 5 + buildSrc/src/main/kotlin/Versions.kt | 1 + core/build.gradle.kts | 9 + .../java/dev/jdtech/jellyfin/di/ApiModule.kt | 10 +- .../dev/jdtech/jellyfin/models/HomeItem.kt | 4 - .../java/dev/jdtech/jellyfin/models/UiText.kt | 12 +- .../dev/jdtech/jellyfin/utils/ComposeUtils.kt | 106 +++++ .../jellyfin/viewmodels/AddServerViewModel.kt | 2 +- .../viewmodels/EpisodeBottomSheetViewModel.kt | 93 ++-- .../jellyfin/viewmodels/HomeViewModel.kt | 21 +- .../jellyfin/viewmodels/MainViewModel.kt | 43 +- .../jellyfin/viewmodels/MovieViewModel.kt | 115 +++-- .../viewmodels/ServerSelectViewModel.kt | 45 +- .../jellyfin/viewmodels/SettingsViewModel.kt | 223 +++++++++ .../jellyfin/viewmodels/ShowViewModel.kt | 111 +++-- .../viewmodels/UserSelectViewModel.kt | 85 ++++ core/src/main/res/drawable/ic_hard_drive.xml | 31 ++ core/src/main/res/drawable/ic_info.xml | 25 ++ core/src/main/res/drawable/ic_logo.xml | 22 + core/src/main/res/drawable/ic_sparkles.xml | 42 ++ core/src/main/res/drawable/ic_tv.xml | 21 + core/src/main/res/mipmap-anydpi/ic_banner.xml | 5 + core/src/main/res/values/plurals.xml | 7 + core/src/main/res/values/strings.xml | 10 + data/build.gradle.kts | 4 + .../jellyfin/database/ServerDatabaseDao.kt | 5 + .../jdtech/jellyfin/models/FindroidBoxSet.kt | 7 +- .../jellyfin/models/FindroidCollection.kt | 7 +- .../jdtech/jellyfin/models/FindroidEpisode.kt | 3 + .../jdtech/jellyfin/models/FindroidImages.kt | 50 +++ .../jdtech/jellyfin/models/FindroidItem.kt | 7 +- .../jdtech/jellyfin/models/FindroidMovie.kt | 9 +- .../jdtech/jellyfin/models/FindroidSeason.kt | 8 +- .../jdtech/jellyfin/models/FindroidShow.kt | 8 +- .../models/ServerWithAddressAndUser.kt | 19 + .../repository/JellyfinRepositoryImpl.kt | 8 +- gradle.properties | 1 - gradle/libs.versions.toml | 39 +- .../java/dev/jdtech/jellyfin/models/Track.kt | 13 + .../java/dev/jdtech/jellyfin/Extensions.kt | 17 + .../dialogs/TrackSelectionDialogFragment.kt | 28 +- .../java/dev/jdtech/jellyfin/mpv/MPVPlayer.kt | 254 ++++------- .../java/dev/jdtech/jellyfin/mpv/TrackType.kt | 14 + .../viewmodels/PlayerActivityViewModel.kt | 31 +- .../jellyfin/viewmodels/PlayerViewModel.kt | 36 +- .../dev/jdtech/jellyfin/AppPreferences.kt | 26 ++ .../dev/jdtech/jellyfin/models/Preference.kt | 9 + .../jellyfin/models/PreferenceCategory.kt | 14 + .../jellyfin/models/PreferenceSelect.kt | 18 + .../jellyfin/models/PreferenceSwitch.kt | 16 + settings.gradle.kts | 1 + 113 files changed, 7504 insertions(+), 489 deletions(-) create mode 100644 app/tv/build.gradle.kts create mode 100644 app/tv/proguard-rules.pro create mode 100644 app/tv/src/main/AndroidManifest.xml create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/Banner.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt create mode 100644 app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt create mode 100644 app/tv/src/main/res/drawable/ic_banner.xml create mode 100644 app/tv/src/main/res/values/themes.xml create mode 100644 core/src/main/java/dev/jdtech/jellyfin/utils/ComposeUtils.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/SettingsViewModel.kt create mode 100644 core/src/main/java/dev/jdtech/jellyfin/viewmodels/UserSelectViewModel.kt create mode 100644 core/src/main/res/drawable/ic_hard_drive.xml create mode 100644 core/src/main/res/drawable/ic_info.xml create mode 100644 core/src/main/res/drawable/ic_logo.xml create mode 100644 core/src/main/res/drawable/ic_sparkles.xml create mode 100644 core/src/main/res/drawable/ic_tv.xml create mode 100644 core/src/main/res/mipmap-anydpi/ic_banner.xml create mode 100644 core/src/main/res/values/plurals.xml create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/FindroidImages.kt create mode 100644 data/src/main/java/dev/jdtech/jellyfin/models/ServerWithAddressAndUser.kt create mode 100644 player/core/src/main/java/dev/jdtech/jellyfin/models/Track.kt create mode 100644 player/video/src/main/java/dev/jdtech/jellyfin/Extensions.kt create mode 100644 preferences/src/main/java/dev/jdtech/jellyfin/models/Preference.kt create mode 100644 preferences/src/main/java/dev/jdtech/jellyfin/models/PreferenceCategory.kt create mode 100644 preferences/src/main/java/dev/jdtech/jellyfin/models/PreferenceSelect.kt create mode 100644 preferences/src/main/java/dev/jdtech/jellyfin/models/PreferenceSwitch.kt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d79253aa..0d19416d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -40,11 +40,6 @@ jobs: - name: Build with Gradle run: ./gradlew assembleDebug # Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged. - - name: Upload artifact phone-libre-universal-debug.apk - uses: actions/upload-artifact@v3 - with: - name: phone-libre-universal-debug.apk - path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-universal-debug.apk - name: Upload artifact phone-libre-arm64-v8a-debug.apk uses: actions/upload-artifact@v3 with: @@ -65,3 +60,23 @@ jobs: with: name: phone-libre-x86-debug.apk path: ./app/phone/build/outputs/apk/libre/debug/phone-libre-x86-debug.apk + - name: Upload artifact tv-libre-arm64-v8a-debug.apk + uses: actions/upload-artifact@v3 + with: + name: tv-libre-arm64-v8a-debug.apk + path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-arm64-v8a-debug.apk + - name: Upload artifact tv-libre-armeabi-v7a-debug.apk + uses: actions/upload-artifact@v3 + with: + name: tv-libre-armeabi-v7a-debug.apk + path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-armeabi-v7a-debug.apk + - name: Upload artifact tv-libre-x86_64-debug.apk + uses: actions/upload-artifact@v3 + with: + name: tv-libre-x86_64-debug.apk + path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86_64-debug.apk + - name: Upload artifact tv-libre-x86-debug.apk + uses: actions/upload-artifact@v3 + with: + name: tv-libre-x86-debug.apk + path: ./app/tv/build/outputs/apk/libre/debug/tv-libre-x86-debug.apk diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts index ae41347b..670bf9fc 100644 --- a/app/phone/build.gradle.kts +++ b/app/phone/build.gradle.kts @@ -57,7 +57,6 @@ android { isEnable = true reset() include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") - isUniversalApk = true } } @@ -67,6 +66,7 @@ android { } buildFeatures { + buildConfig = true viewBinding = true } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt index 5096d4fc..7e3b5a32 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt @@ -22,8 +22,8 @@ class BaseApplication : Application(), Configuration.Provider, ImageLoaderFactor @Inject lateinit var workerFactory: HiltWorkerFactory - override fun getWorkManagerConfiguration() = - Configuration.Builder() + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt index c85c7167..f5417015 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/adapters/ViewListAdapter.kt @@ -121,7 +121,6 @@ class ViewListAdapter( override fun getItemViewType(position: Int): Int { return when (getItem(position)) { is HomeItem.OfflineCard -> ITEM_VIEW_TYPE_OFFLINE_CARD - is HomeItem.Libraries -> -1 is HomeItem.Section -> ITEM_VIEW_TYPE_NEXT_UP is HomeItem.ViewItem -> ITEM_VIEW_TYPE_VIEW } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt index 3ec17bb1..c9c1066a 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/EpisodeBottomSheetFragment.kt @@ -35,6 +35,7 @@ import dev.jdtech.jellyfin.models.isDownloading import dev.jdtech.jellyfin.utils.setIconTintColorAttribute import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetEvent import dev.jdtech.jellyfin.viewmodels.EpisodeBottomSheetViewModel +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import kotlinx.coroutines.launch import org.jellyfin.sdk.model.DateTime @@ -130,13 +131,15 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } } - } - } - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> - when (playerItems) { - is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) - is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) + launch { + playerViewModel.eventsChannelFlow.collect { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items) + is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error) + } + } + } } } @@ -145,13 +148,11 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } binding.itemActions.checkButton.setOnClickListener { - val played = viewModel.togglePlayed() - bindCheckButtonState(played) + viewModel.togglePlayed() } binding.itemActions.favoriteButton.setOnClickListener { - val favorite = viewModel.toggleFavorite() - bindFavoriteButtonState(favorite) + viewModel.toggleFavorite() } binding.itemActions.downloadButton.setOnClickListener { @@ -310,8 +311,8 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { binding.overview.text = uiState.error.message } - private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { - navigateToPlayerActivity(items.items.toTypedArray()) + private fun bindPlayerItems(items: List) { + navigateToPlayerActivity(items.toTypedArray()) binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play) binding.itemActions.progressPlay.visibility = View.INVISIBLE } @@ -347,12 +348,12 @@ class EpisodeBottomSheetFragment : BottomSheetDialogFragment() { } } - private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { - Timber.e(error.error.message) + private fun bindPlayerItemsError(error: Exception) { + Timber.e(error.message) binding.playerItemsError.isVisible = true playButtonNormal() binding.playerItemsErrorDetails.setOnClickListener { - ErrorDialogFragment.newInstance(error.error).show(parentFragmentManager, ErrorDialogFragment.TAG) + ErrorDialogFragment.newInstance(error).show(parentFragmentManager, ErrorDialogFragment.TAG) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt index 781e240d..be6d3d40 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/MovieFragment.kt @@ -39,6 +39,7 @@ import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.setIconTintColorAttribute import dev.jdtech.jellyfin.viewmodels.MovieEvent import dev.jdtech.jellyfin.viewmodels.MovieViewModel +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -126,6 +127,15 @@ class MovieFragment : Fragment() { } } } + + launch { + playerViewModel.eventsChannelFlow.collect { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items) + is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error) + } + } + } } } @@ -137,13 +147,6 @@ class MovieFragment : Fragment() { errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) } - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> - when (playerItems) { - is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) - is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) - } - } - binding.itemActions.playButton.setOnClickListener { binding.itemActions.playButton.isEnabled = false binding.itemActions.playButton.setIconResource(android.R.color.transparent) @@ -180,13 +183,11 @@ class MovieFragment : Fragment() { } binding.itemActions.checkButton.setOnClickListener { - val played = viewModel.togglePlayed() - bindCheckButtonState(played) + viewModel.togglePlayed() } binding.itemActions.favoriteButton.setOnClickListener { - val favorite = viewModel.toggleFavorite() - bindFavoriteButtonState(favorite) + viewModel.toggleFavorite() } binding.itemActions.downloadButton.setOnClickListener { @@ -433,18 +434,18 @@ class MovieFragment : Fragment() { } } - private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { - navigateToPlayerActivity(items.items.toTypedArray()) + private fun bindPlayerItems(items: List) { + navigateToPlayerActivity(items.toTypedArray()) binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play) binding.itemActions.progressPlay.visibility = View.INVISIBLE } - private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { - Timber.e(error.error.message) + private fun bindPlayerItemsError(error: Exception) { + Timber.e(error.message) binding.playerItemsError.visibility = View.VISIBLE playButtonNormal() binding.playerItemsErrorDetails.setOnClickListener { - ErrorDialogFragment.newInstance(error.error) + ErrorDialogFragment.newInstance(error) .show(parentFragmentManager, ErrorDialogFragment.TAG) } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt index 66cff353..8bc559f8 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/SeasonFragment.kt @@ -17,9 +17,7 @@ import dev.jdtech.jellyfin.adapters.EpisodeListAdapter import dev.jdtech.jellyfin.databinding.FragmentSeasonBinding import dev.jdtech.jellyfin.dialogs.ErrorDialogFragment import dev.jdtech.jellyfin.models.FindroidEpisode -import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.utils.checkIfLoginRequired -import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.SeasonEvent import dev.jdtech.jellyfin.viewmodels.SeasonViewModel import kotlinx.coroutines.launch @@ -30,7 +28,6 @@ class SeasonFragment : Fragment() { private lateinit var binding: FragmentSeasonBinding private val viewModel: SeasonViewModel by viewModels() - private val playerViewModel: PlayerViewModel by viewModels() private val args: SeasonFragmentArgs by navArgs() private lateinit var errorDialog: ErrorDialogFragment @@ -74,15 +71,6 @@ class SeasonFragment : Fragment() { viewModel.loadEpisodes(args.seriesId, args.seasonId, args.offline) } - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> - when (playerItems) { - is PlayerViewModel.PlayerItems -> { - navigateToPlayerActivity(playerItems.items.toTypedArray()) - } - is PlayerViewModel.PlayerItemError -> {} - } - } - binding.errorLayout.errorDetailsButton.setOnClickListener { errorDialog.show(parentFragmentManager, ErrorDialogFragment.TAG) } @@ -129,14 +117,4 @@ class SeasonFragment : Fragment() { ), ) } - - private fun navigateToPlayerActivity( - playerItems: Array, - ) { - findNavController().navigate( - SeasonFragmentDirections.actionSeasonFragmentToPlayerActivity( - playerItems, - ), - ) - } } diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt index 940a10e6..21be0c63 100644 --- a/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt +++ b/app/phone/src/main/java/dev/jdtech/jellyfin/fragments/ShowFragment.kt @@ -31,6 +31,7 @@ import dev.jdtech.jellyfin.models.PlayerItem import dev.jdtech.jellyfin.models.isDownloaded import dev.jdtech.jellyfin.utils.checkIfLoginRequired import dev.jdtech.jellyfin.utils.setIconTintColorAttribute +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent import dev.jdtech.jellyfin.viewmodels.PlayerViewModel import dev.jdtech.jellyfin.viewmodels.ShowEvent import dev.jdtech.jellyfin.viewmodels.ShowViewModel @@ -86,6 +87,15 @@ class ShowFragment : Fragment() { } } } + + launch { + playerViewModel.eventsChannelFlow.collect { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> bindPlayerItems(event.items) + is PlayerItemsEvent.PlayerItemsError -> bindPlayerItemsError(event.error) + } + } + } } } @@ -96,13 +106,6 @@ class ShowFragment : Fragment() { viewModel.loadData(args.itemId, args.offline) } - playerViewModel.onPlaybackRequested(lifecycleScope) { playerItems -> - when (playerItems) { - is PlayerViewModel.PlayerItemError -> bindPlayerItemsError(playerItems) - is PlayerViewModel.PlayerItems -> bindPlayerItems(playerItems) - } - } - binding.itemActions.trailerButton.setOnClickListener { viewModel.item.trailer.let { trailerUri -> val intent = Intent( @@ -144,13 +147,11 @@ class ShowFragment : Fragment() { } binding.itemActions.checkButton.setOnClickListener { - val played = viewModel.togglePlayed() - bindCheckButtonState(played) + viewModel.togglePlayed() } binding.itemActions.favoriteButton.setOnClickListener { - val favorite = viewModel.toggleFavorite() - bindFavoriteButtonState(favorite) + viewModel.toggleFavorite() } } @@ -290,18 +291,18 @@ class ShowFragment : Fragment() { } } - private fun bindPlayerItems(items: PlayerViewModel.PlayerItems) { - navigateToPlayerActivity(items.items.toTypedArray()) + private fun bindPlayerItems(items: List) { + navigateToPlayerActivity(items.toTypedArray()) binding.itemActions.playButton.setIconResource(CoreR.drawable.ic_play) binding.itemActions.progressPlay.visibility = View.INVISIBLE } - private fun bindPlayerItemsError(error: PlayerViewModel.PlayerItemError) { - Timber.e(error.error.message) + private fun bindPlayerItemsError(error: Exception) { + Timber.e(error.message) binding.playerItemsError.visibility = View.VISIBLE playButtonNormal() binding.playerItemsErrorDetails.setOnClickListener { - ErrorDialogFragment.newInstance(error.error) + ErrorDialogFragment.newInstance(error) .show(parentFragmentManager, ErrorDialogFragment.TAG) } } diff --git a/app/tv/build.gradle.kts b/app/tv/build.gradle.kts new file mode 100644 index 00000000..f76a759f --- /dev/null +++ b/app/tv/build.gradle.kts @@ -0,0 +1,112 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + alias(libs.plugins.ktlint) +} + +android { + namespace = "dev.jdtech.jellyfin" + compileSdk = Versions.compileSdk + buildToolsVersion = Versions.buildTools + + defaultConfig { + applicationId = "dev.jdtech.jellyfin" + minSdk = Versions.minSdk + targetSdk = Versions.targetSdk + + versionCode = Versions.appCode + versionName = Versions.appName + } + + buildTypes { + named("debug") { + applicationIdSuffix = ".debug" + } + named("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + register("staging") { + initWith(getByName("release")) + applicationIdSuffix = ".staging" + } + } + + flavorDimensions += "variant" + productFlavors { + register("libre") { + dimension = "variant" + isDefault = true + } + } + + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + compileOptions { + sourceCompatibility = Versions.java + targetCompatibility = Versions.java + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.composeCompiler + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +ktlint { + version.set(Versions.ktlint) + android.set(true) + ignoreFailures.set(false) +} + +dependencies { + implementation(project(":core")) + implementation(project(":data")) + implementation(project(":preferences")) + implementation(project(":player:core")) + implementation(project(":player:video")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.core) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.paging.compose) + implementation(libs.coil.compose) + implementation(libs.coil.svg) + implementation(libs.compose.destinations.core) + ksp(libs.compose.destinations.ksp) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.jellyfin.core) + implementation(libs.androidx.tv.foundation) + implementation(libs.androidx.tv.material) + + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/app/tv/proguard-rules.pro b/app/tv/proguard-rules.pro new file mode 100644 index 00000000..04ec1836 --- /dev/null +++ b/app/tv/proguard-rules.pro @@ -0,0 +1,30 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# These classes are from okhttp and are not used in Android +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.* +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file diff --git a/app/tv/src/main/AndroidManifest.xml b/app/tv/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c69c9010 --- /dev/null +++ b/app/tv/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt new file mode 100644 index 00000000..15dc6cea --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt @@ -0,0 +1,18 @@ +package dev.jdtech.jellyfin + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.decode.SvgDecoder +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class BaseApplication : Application(), ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .components { + add(SvgDecoder.Factory()) + } + .build() + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt new file mode 100644 index 00000000..0d2ed58d --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/MainActivity.kt @@ -0,0 +1,90 @@ +package dev.jdtech.jellyfin + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NonInteractiveSurfaceDefaults +import androidx.tv.material3.Surface +import com.ramcosta.composedestinations.DestinationsNavHost +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.database.ServerDatabaseDao +import dev.jdtech.jellyfin.destinations.AddServerScreenDestination +import dev.jdtech.jellyfin.destinations.LoginScreenDestination +import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.viewmodels.MainViewModel +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val viewModel: MainViewModel by viewModels() + + @Inject + lateinit var database: ServerDatabaseDao + + @Inject + lateinit var appPreferences: AppPreferences + + @OptIn(ExperimentalTvMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + var startRoute = NavGraphs.root.startRoute + if (checkServersEmpty()) { + startRoute = AddServerScreenDestination + } else if (checkUser()) { + startRoute = LoginScreenDestination + } + + // TODO remove temp always show server selection screen + startRoute = ServerSelectScreenDestination + + setContent { + FindroidTheme { + Surface( + colors = NonInteractiveSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + ), + shape = RectangleShape, + modifier = Modifier.fillMaxSize(), + ) { + DestinationsNavHost( + navGraph = NavGraphs.root, + startRoute = startRoute, + ) + } + } + } + } + + private fun checkServersEmpty(): Boolean { + if (!viewModel.startDestinationChanged) { + val nServers = database.getServersCount() + if (nServers < 1) { + viewModel.startDestinationChanged = true + return true + } + } + return false + } + + private fun checkUser(): Boolean { + if (!viewModel.startDestinationChanged) { + appPreferences.currentServer?.let { + val currentUser = database.getServerCurrentUser(it) + if (currentUser == null) { + viewModel.startDestinationChanged = true + return true + } + } + } + return false + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt new file mode 100644 index 00000000..950cdd4a --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt @@ -0,0 +1,52 @@ +package dev.jdtech.jellyfin + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.annotation.ActivityDestination +import com.ramcosta.composedestinations.manualcomposablecalls.composable +import com.ramcosta.composedestinations.scope.resultRecipient +import dagger.hilt.android.AndroidEntryPoint +import dev.jdtech.jellyfin.destinations.PlayerActivityDestination +import dev.jdtech.jellyfin.destinations.PlayerScreenDestination +import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.ui.PlayerScreen +import dev.jdtech.jellyfin.ui.theme.FindroidTheme + +data class PlayerActivityNavArgs( + val items: ArrayList, +) + +@AndroidEntryPoint +@ActivityDestination( + navArgsDelegate = PlayerActivityNavArgs::class, +) +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class PlayerActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val args = PlayerActivityDestination.argsFrom(intent) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + setContent { + FindroidTheme { + DestinationsNavHost( + navGraph = NavGraphs.root, + startRoute = PlayerScreenDestination, + ) { + composable(PlayerScreenDestination) { + PlayerScreen( + navigator = destinationsNavigator, + items = args.items, + resultRecipient = resultRecipient(), + ) + } + } + } + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt new file mode 100644 index 00000000..61fea283 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/AddServerScreen.kt @@ -0,0 +1,194 @@ +package dev.jdtech.jellyfin.ui + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.LoginScreenDestination +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.AddServerEvent +import dev.jdtech.jellyfin.viewmodels.AddServerViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun AddServerScreen( + navigator: DestinationsNavigator, + addServerViewModel: AddServerViewModel = hiltViewModel(), +) { + val uiState by addServerViewModel.uiState.collectAsState() + + ObserveAsEvents(addServerViewModel.eventsChannelFlow) { event -> + when (event) { + is AddServerEvent.NavigateToLogin -> { + navigator.navigate(LoginScreenDestination) + } + } + } + + AddServerScreenLayout( + uiState = uiState, + onConnectClick = { serverAddress -> + addServerViewModel.checkServer(serverAddress) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun AddServerScreenLayout( + uiState: AddServerViewModel.UiState, + onConnectClick: (String) -> Unit, +) { + var serverAddress by rememberSaveable { + mutableStateOf("") + } + val isError = uiState is AddServerViewModel.UiState.Error + val isLoading = uiState is AddServerViewModel.UiState.Loading + val context = LocalContext.current + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + Text( + text = stringResource(id = CoreR.string.add_server), + style = MaterialTheme.typography.displayMedium, + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + OutlinedTextField( + value = serverAddress, + leadingIcon = { + Icon( + painter = painterResource(id = CoreR.drawable.ic_server), + contentDescription = null, + ) + }, + onValueChange = { serverAddress = it }, + label = { + Text( + text = stringResource(id = CoreR.string.edit_text_server_address_hint), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + isError = isError, + enabled = !isLoading, + supportingText = { + if (isError) { + Text( + text = (uiState as AddServerViewModel.UiState.Error).message.joinToString { + it.asString( + context.resources, + ) + }, + color = MaterialTheme.colorScheme.error, + ) + } + }, + modifier = Modifier + .width(360.dp) + .focusRequester(focusRequester), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + Box { + Button( + onClick = { + onConnectClick(serverAddress) + }, + enabled = !isLoading, + modifier = Modifier.width(360.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + if (isLoading) { + CircularProgressIndicator( + color = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterStart), + ) + } + Text( + text = stringResource(id = CoreR.string.button_connect), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun AddServerScreenLayoutPreview() { + FindroidTheme { + Surface { + AddServerScreenLayout( + uiState = AddServerViewModel.UiState.Normal, + onConnectClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt new file mode 100644 index 00000000..a2377448 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/HomeScreen.kt @@ -0,0 +1,212 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.MovieScreenDestination +import dev.jdtech.jellyfin.destinations.PlayerActivityDestination +import dev.jdtech.jellyfin.destinations.ShowScreenDestination +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow +import dev.jdtech.jellyfin.models.HomeItem +import dev.jdtech.jellyfin.ui.components.Direction +import dev.jdtech.jellyfin.ui.components.ItemCard +import dev.jdtech.jellyfin.ui.dummy.dummyHomeItems +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.HomeViewModel +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun HomeScreen( + navigator: DestinationsNavigator, + homeViewModel: HomeViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), + isLoading: (Boolean) -> Unit, +) { + LaunchedEffect(key1 = true) { + homeViewModel.loadData() + } + + ObserveAsEvents(playerViewModel.eventsChannelFlow) { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> { + navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items))) + } + is PlayerItemsEvent.PlayerItemsError -> Unit + } + } + + val delegatedUiState by homeViewModel.uiState.collectAsState() + + HomeScreenLayout( + uiState = delegatedUiState, + isLoading = isLoading, + onClick = { item -> + when (item) { + is FindroidMovie -> { + navigator.navigate(MovieScreenDestination(item.id)) + } + is FindroidShow -> { + navigator.navigate(ShowScreenDestination(item.id)) + } + is FindroidEpisode -> { + playerViewModel.loadPlayerItems(item = item) + } + } + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun HomeScreenLayout( + uiState: HomeViewModel.UiState, + isLoading: (Boolean) -> Unit, + onClick: (FindroidItem) -> Unit, +) { + var homeItems: List by remember { mutableStateOf(emptyList()) } + + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is HomeViewModel.UiState.Normal -> { + homeItems = uiState.homeItems + isLoading(false) + } + is HomeViewModel.UiState.Loading -> { + isLoading(true) + } + else -> Unit + } + TvLazyColumn( + contentPadding = PaddingValues(bottom = MaterialTheme.spacings.large), + modifier = Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) { + items(homeItems, key = { it.id }) { homeItem -> + when (homeItem) { + is HomeItem.Section -> { + Text( + text = homeItem.homeSection.name.asString(), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(start = MaterialTheme.spacings.large), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large), + ) { + items(homeItem.homeSection.items, key = { it.id }) { item -> + ItemCard( + item = item, + direction = Direction.HORIZONTAL, + onClick = { + onClick(it) + }, + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + } + is HomeItem.ViewItem -> { + Text( + text = stringResource(id = CoreR.string.latest_library, homeItem.view.name), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(start = MaterialTheme.spacings.large), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.large), + ) { + items(homeItem.view.items.orEmpty(), key = { it.id }) { item -> + ItemCard( + item = item, + direction = Direction.VERTICAL, + onClick = { + onClick(it) + }, + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + } + else -> Unit + } + } + } + LaunchedEffect(homeItems) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun HomeScreenLayoutPreview() { + FindroidTheme { + Surface { + HomeScreenLayout( + uiState = HomeViewModel.UiState.Normal(dummyHomeItems), + isLoading = {}, + onClick = {}, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun Header(modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(80.dp), + ) { + Image( + painter = painterResource(id = CoreR.drawable.ic_banner), + contentDescription = null, + modifier = Modifier.height(40.dp), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt new file mode 100644 index 00000000..a883fdf5 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibrariesScreen.kt @@ -0,0 +1,118 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.grid.TvGridCells +import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid +import androidx.tv.foundation.lazy.grid.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.LibraryScreenDestination +import dev.jdtech.jellyfin.models.CollectionType +import dev.jdtech.jellyfin.models.FindroidCollection +import dev.jdtech.jellyfin.ui.components.Direction +import dev.jdtech.jellyfin.ui.components.ItemCard +import dev.jdtech.jellyfin.ui.dummy.dummyCollections +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.viewmodels.MediaViewModel +import java.util.UUID + +@Destination +@Composable +fun LibrariesScreen( + navigator: DestinationsNavigator, + isLoading: (Boolean) -> Unit, + mediaViewModel: MediaViewModel = hiltViewModel(), +) { + val delegatedUiState by mediaViewModel.uiState.collectAsState() + + LibrariesScreenLayout( + uiState = delegatedUiState, + isLoading = isLoading, + onClick = { libraryId, libraryName, libraryType -> + navigator.navigate(LibraryScreenDestination(libraryId, libraryName, libraryType)) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun LibrariesScreenLayout( + uiState: MediaViewModel.UiState, + isLoading: (Boolean) -> Unit, + onClick: (UUID, String, CollectionType) -> Unit, +) { + var collections: List by remember { + mutableStateOf(emptyList()) + } + + when (uiState) { + is MediaViewModel.UiState.Normal -> { + collections = uiState.collections + isLoading(false) + } + is MediaViewModel.UiState.Loading -> { + isLoading(true) + } + else -> Unit + } + + val focusRequester = remember { FocusRequester() } + + TvLazyVerticalGrid( + columns = TvGridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + contentPadding = PaddingValues( + start = MaterialTheme.spacings.large, + top = MaterialTheme.spacings.small, + end = MaterialTheme.spacings.large, + bottom = MaterialTheme.spacings.large, + ), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(collections, key = { it.id }) { collection -> + ItemCard( + item = collection, + direction = Direction.HORIZONTAL, + onClick = { + onClick(collection.id, collection.name, collection.type) + }, + ) + } + } + LaunchedEffect(collections) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun LibrariesScreenLayoutPreview() { + FindroidTheme { + Surface { + LibrariesScreenLayout( + uiState = MediaViewModel.UiState.Normal(dummyCollections), + isLoading = {}, + onClick = { _, _, _ -> }, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt new file mode 100644 index 00000000..fccb32ea --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LibraryScreen.kt @@ -0,0 +1,143 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.tv.foundation.lazy.grid.TvGridCells +import androidx.tv.foundation.lazy.grid.TvGridItemSpan +import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.MovieScreenDestination +import dev.jdtech.jellyfin.destinations.ShowScreenDestination +import dev.jdtech.jellyfin.models.CollectionType +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidShow +import dev.jdtech.jellyfin.ui.components.Direction +import dev.jdtech.jellyfin.ui.components.ItemCard +import dev.jdtech.jellyfin.ui.dummy.dummyMovies +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.viewmodels.LibraryViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.util.UUID + +@Destination +@Composable +fun LibraryScreen( + navigator: DestinationsNavigator, + libraryId: UUID, + libraryName: String, + libraryType: CollectionType, + libraryViewModel: LibraryViewModel = hiltViewModel(), +) { + LaunchedEffect(true) { + libraryViewModel.loadItems(libraryId, libraryType) + } + + val delegatedUiState by libraryViewModel.uiState.collectAsState() + + LibraryScreenLayout( + libraryName = libraryName, + uiState = delegatedUiState, + onClick = { item -> + when (item) { + is FindroidMovie -> { + navigator.navigate(MovieScreenDestination(item.id)) + } + is FindroidShow -> { + navigator.navigate(ShowScreenDestination(item.id)) + } + } + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun LibraryScreenLayout( + libraryName: String, + uiState: LibraryViewModel.UiState, + onClick: (FindroidItem) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is LibraryViewModel.UiState.Loading -> Text(text = "LOADING") + is LibraryViewModel.UiState.Normal -> { + val items = uiState.items.collectAsLazyPagingItems() + TvLazyVerticalGrid( + columns = TvGridCells.Fixed(5), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large), + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))) + .focusRequester(focusRequester), + ) { + item(span = { TvGridItemSpan(this.maxLineSpan) }) { + Text( + text = libraryName, + style = MaterialTheme.typography.displayMedium, + ) + } + items(items.itemCount) { i -> + val item = items[i] + item?.let { + ItemCard( + item = item, + direction = Direction.VERTICAL, + onClick = { + onClick(item) + }, + ) + } + } + } + LaunchedEffect(items.itemCount > 0) { + if (items.itemCount > 0) { + focusRequester.requestFocus() + } + } + } + is LibraryViewModel.UiState.Error -> Text(text = uiState.error.toString()) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun LibraryScreenLayoutPreview() { + val data: Flow> = flowOf(PagingData.from(dummyMovies)) + FindroidTheme { + Surface { + LibraryScreenLayout( + libraryName = "Movies", + uiState = LibraryViewModel.UiState.Normal(data), + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt new file mode 100644 index 00000000..c0b40eb7 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/LoginScreen.kt @@ -0,0 +1,287 @@ +package dev.jdtech.jellyfin.ui + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import dev.jdtech.jellyfin.NavGraphs +import dev.jdtech.jellyfin.destinations.MainScreenDestination +import dev.jdtech.jellyfin.models.UiText +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.LoginEvent +import dev.jdtech.jellyfin.viewmodels.LoginViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun LoginScreen( + navigator: DestinationsNavigator, + loginViewModel: LoginViewModel = hiltViewModel(), +) { + val delegatedUiState by loginViewModel.uiState.collectAsState() + val delegatedQuickConnectUiState by loginViewModel.quickConnectUiState.collectAsState( + initial = LoginViewModel.QuickConnectUiState.Disabled, + ) + + ObserveAsEvents(loginViewModel.eventsChannelFlow) { event -> + when (event) { + is LoginEvent.NavigateToHome -> { + navigator.navigate(MainScreenDestination) { + popUpTo(NavGraphs.root) { + inclusive = true + } + } + } + } + } + + LoginScreenLayout( + uiState = delegatedUiState, + quickConnectUiState = delegatedQuickConnectUiState, + onLoginClick = { username, password -> + loginViewModel.login(username, password) + }, + onQuickConnectClick = { + loginViewModel.useQuickConnect() + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun LoginScreenLayout( + uiState: LoginViewModel.UiState, + quickConnectUiState: LoginViewModel.QuickConnectUiState, + onLoginClick: (String, String) -> Unit, + onQuickConnectClick: () -> Unit, +) { + var username by rememberSaveable { + mutableStateOf("") + } + var password by rememberSaveable { + mutableStateOf("") + } + + var quickConnectValue = stringResource(id = CoreR.string.quick_connect) + + when (quickConnectUiState) { + is LoginViewModel.QuickConnectUiState.Waiting -> { + quickConnectValue = quickConnectUiState.code + } + else -> Unit + } + + val isError = uiState is LoginViewModel.UiState.Error + val isLoading = uiState is LoginViewModel.UiState.Loading + + val quickConnectEnabled = quickConnectUiState !is LoginViewModel.QuickConnectUiState.Disabled + val isWaiting = quickConnectUiState is LoginViewModel.QuickConnectUiState.Waiting + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + Text( + text = stringResource(id = CoreR.string.login), + style = MaterialTheme.typography.displayMedium, + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + OutlinedTextField( + value = username, + leadingIcon = { + Icon( + painter = painterResource(id = CoreR.drawable.ic_user), + contentDescription = null, + ) + }, + onValueChange = { username = it }, + label = { Text(text = stringResource(id = CoreR.string.edit_text_username_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + isError = isError, + enabled = !isLoading, + modifier = Modifier + .width(360.dp) + .focusRequester(focusRequester), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + OutlinedTextField( + value = password, + leadingIcon = { + Icon( + painter = painterResource(id = CoreR.drawable.ic_lock), + contentDescription = null, + ) + }, + onValueChange = { password = it }, + label = { Text(text = stringResource(id = CoreR.string.edit_text_password_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + visualTransformation = PasswordVisualTransformation(), + isError = isError, + enabled = !isLoading, + supportingText = { + if (isError) { + Text( + text = (uiState as LoginViewModel.UiState.Error).message.asString(), + color = MaterialTheme.colorScheme.error, + ) + } + }, + modifier = Modifier + .width(360.dp), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + Box { + Button( + onClick = { + onLoginClick(username, password) + }, + enabled = !isLoading, + modifier = Modifier.width(360.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + if (isLoading) { + CircularProgressIndicator( + color = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterStart), + ) + } + Text( + text = stringResource(id = CoreR.string.button_login), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + if (quickConnectEnabled) { + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + Box { + OutlinedButton( + onClick = { + onQuickConnectClick() + }, + modifier = Modifier.width(360.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + if (isWaiting) { + CircularProgressIndicator( + color = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterStart), + ) + } + Text( + text = quickConnectValue, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun LoginScreenLayoutPreview() { + FindroidTheme { + Surface { + LoginScreenLayout( + uiState = LoginViewModel.UiState.Normal, + quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal, + onLoginClick = { _, _ -> }, + onQuickConnectClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun LoginScreenLayoutPreviewError() { + FindroidTheme { + Surface { + LoginScreenLayout( + uiState = LoginViewModel.UiState.Error(UiText.DynamicString("Invalid username or password")), + quickConnectUiState = LoginViewModel.QuickConnectUiState.Normal, + onLoginClick = { _, _ -> }, + onQuickConnectClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt new file mode 100644 index 00000000..0f37b271 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MainScreen.kt @@ -0,0 +1,210 @@ +package dev.jdtech.jellyfin.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Tab +import androidx.tv.material3.TabDefaults +import androidx.tv.material3.TabRow +import androidx.tv.material3.TabRowDefaults +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import dev.jdtech.jellyfin.destinations.SettingsScreenDestination +import dev.jdtech.jellyfin.models.User +import dev.jdtech.jellyfin.ui.components.LoadingIndicator +import dev.jdtech.jellyfin.ui.components.PillBorderIndicator +import dev.jdtech.jellyfin.ui.components.ProfileButton +import dev.jdtech.jellyfin.ui.dummy.dummyServer +import dev.jdtech.jellyfin.ui.dummy.dummyUser +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.viewmodels.MainViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@RootNavGraph(start = true) +@Destination +@Composable +fun MainScreen( + mainViewModel: MainViewModel = hiltViewModel(), + navigator: DestinationsNavigator, +) { + val delegatedUiState by mainViewModel.uiState.collectAsState() + + MainScreenLayout( + uiState = delegatedUiState, + navigator = navigator, + ) +} + +enum class TabDestination( + @DrawableRes val icon: Int, + @StringRes val label: Int, +) { + Search(CoreR.drawable.ic_search, CoreR.string.search), + Home(CoreR.drawable.ic_home, CoreR.string.title_home), + Libraries(CoreR.drawable.ic_library, CoreR.string.libraries), + // LiveTV(CoreR.drawable.ic_tv, CoreR.string.live_tv) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun MainScreenLayout( + uiState: MainViewModel.UiState, + navigator: DestinationsNavigator, +) { + var focusedTabIndex by rememberSaveable { mutableIntStateOf(1) } + var activeTabIndex by rememberSaveable { mutableIntStateOf(focusedTabIndex) } + + var isLoading by remember { mutableStateOf(false) } + + var user: User? = null + when (uiState) { + is MainViewModel.UiState.Normal -> { + user = uiState.user + } + else -> Unit + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = MaterialTheme.spacings.default), + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_logo), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(32.dp) + .align(Alignment.CenterStart), + ) + TabRow( + selectedTabIndex = focusedTabIndex, + indicator = { tabPositions, isActivated -> + // FocusedTab's indicator + PillBorderIndicator( + currentTabPosition = tabPositions[focusedTabIndex], + activeBorderColor = Color.White, + inactiveBorderColor = Color.Transparent, + doesTabRowHaveFocus = isActivated, + ) + + // SelectedTab's indicator + TabRowDefaults.PillIndicator( + currentTabPosition = tabPositions[activeTabIndex], + activeColor = Color.White, + inactiveColor = Color.White, + doesTabRowHaveFocus = isActivated, + ) + }, + modifier = Modifier.align(Alignment.Center), + ) { + TabDestination.entries.forEachIndexed { index, tab -> + Tab( + selected = activeTabIndex == index, + onFocus = { focusedTabIndex = index }, + colors = TabDefaults.pillIndicatorTabColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + selectedContentColor = MaterialTheme.colorScheme.onPrimary, + focusedContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + focusedSelectedContentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + focusedTabIndex = index + activeTabIndex = index + }, + modifier = Modifier.padding(horizontal = MaterialTheme.spacings.default / 2, vertical = MaterialTheme.spacings.small), + ) { + Icon( + painter = painterResource(id = tab.icon), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall)) + Text( + text = stringResource(id = tab.label), + style = MaterialTheme.typography.titleSmall, + ) + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + if (isLoading) { + LoadingIndicator() + } + ProfileButton( + user = user, + onClick = { + navigator.navigate(SettingsScreenDestination()) + }, + ) + } + } + when (activeTabIndex) { + 1 -> { + HomeScreen(navigator = navigator, isLoading = { isLoading = it }) + } + 2 -> { + LibrariesScreen(navigator = navigator, isLoading = { isLoading = it }) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun MainScreenLayoutPreview() { + FindroidTheme { + Surface { + MainScreenLayout( + uiState = MainViewModel.UiState.Normal(server = dummyServer, user = dummyUser), + navigator = EmptyDestinationsNavigator, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt new file mode 100644 index 00000000..3de5479f --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/MovieScreen.kt @@ -0,0 +1,375 @@ +package dev.jdtech.jellyfin.ui + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.PlayerActivityDestination +import dev.jdtech.jellyfin.models.AudioChannel +import dev.jdtech.jellyfin.models.AudioCodec +import dev.jdtech.jellyfin.models.DisplayProfile +import dev.jdtech.jellyfin.models.Resolution +import dev.jdtech.jellyfin.models.VideoMetadata +import dev.jdtech.jellyfin.ui.dummy.dummyMovie +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.Yellow +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.MovieViewModel +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import org.jellyfin.sdk.model.api.BaseItemPerson +import java.util.UUID +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun MovieScreen( + navigator: DestinationsNavigator, + itemId: UUID, + movieViewModel: MovieViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), +) { + val context = LocalContext.current + LaunchedEffect(Unit) { + movieViewModel.loadData(itemId) + } + + ObserveAsEvents(playerViewModel.eventsChannelFlow) { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> { + navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items))) + } + is PlayerItemsEvent.PlayerItemsError -> Unit + } + } + + val delegatedUiState by movieViewModel.uiState.collectAsState() + + MovieScreenLayout( + uiState = delegatedUiState, + onPlayClick = { + playerViewModel.loadPlayerItems(movieViewModel.item) + }, + onTrailerClick = { trailerUri -> + try { + Intent( + Intent.ACTION_VIEW, + Uri.parse(trailerUri), + ).also { + context.startActivity(it) + } + } catch (e: Exception) { + Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() + } + }, + onPlayedClick = { + movieViewModel.togglePlayed() + }, + onFavoriteClick = { + movieViewModel.toggleFavorite() + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun MovieScreenLayout( + uiState: MovieViewModel.UiState, + onPlayClick: () -> Unit, + onTrailerClick: (String) -> Unit, + onPlayedClick: () -> Unit, + onFavoriteClick: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is MovieViewModel.UiState.Loading -> Text(text = "LOADING") + is MovieViewModel.UiState.Normal -> { + val item = uiState.item + var size by remember { + mutableStateOf(Size.Zero) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + size = coordinates.size.toSize() + }, + ) { + AsyncImage( + model = item.images.backdrop, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize(), + ) + if (size != Size.Zero) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + listOf(Color.Black.copy(alpha = .2f), Color.Black), + center = Offset(size.width, 0f), + radius = size.width * .8f, + ), + ), + ) + } + Column( + modifier = Modifier + .padding(start = MaterialTheme.spacings.default * 2, end = MaterialTheme.spacings.default * 2), + ) { + Spacer(modifier = Modifier.height(112.dp)) + Text( + text = item.name, + style = MaterialTheme.typography.displayMedium, + ) + if (item.originalTitle != item.name) { + item.originalTitle?.let { originalTitle -> + Text( + text = originalTitle, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.small)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small), + ) { + Text( + text = uiState.dateString, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = uiState.runTime, + style = MaterialTheme.typography.labelMedium, + ) + item.officialRating?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + ) + } + item.communityRating?.let { + Row { + Icon( + painter = painterResource(id = CoreR.drawable.ic_star), + contentDescription = null, + tint = Yellow, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall)) + Text( + text = String.format("%.1f", item.communityRating), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + Text( + text = item.overview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(640.dp), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + ) { + Button( + onClick = { + onPlayClick() + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_play), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = CoreR.string.play)) + } + item.trailer?.let { trailerUri -> + Button( + onClick = { + onTrailerClick(trailerUri) + }, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_film), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = CoreR.string.watch_trailer)) + } + } + Button( + onClick = { + onPlayedClick() + }, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_check), + contentDescription = null, + tint = if (item.played) Color.Red else LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played)) + } + Button( + onClick = { + onFavoriteClick() + }, + ) { + Icon( + painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart), + contentDescription = null, + tint = if (item.favorite) Color.Red else LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites)) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + ) { + Column { + Text( + text = stringResource(id = CoreR.string.genres), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = uiState.genresString, + style = MaterialTheme.typography.bodyMedium, + ) + } + uiState.director?.let { director -> + Column { + Text( + text = stringResource(id = CoreR.string.director), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = director.name ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Column { + Text( + text = stringResource(id = CoreR.string.writers), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = uiState.writersString, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +// Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) +// Text( +// text = stringResource(id = CoreR.string.cast_amp_crew), +// style = MaterialTheme.typography.headlineMedium, +// ) + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + + is MovieViewModel.UiState.Error -> Text(text = uiState.error.toString()) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun MovieScreenLayoutPreview() { + FindroidTheme { + Surface { + MovieScreenLayout( + uiState = MovieViewModel.UiState.Normal( + item = dummyMovie, + actors = emptyList(), + director = BaseItemPerson( + id = UUID.randomUUID(), + name = "Robert Rodriguez", + ), + writers = emptyList(), + videoMetadata = VideoMetadata( + resolution = listOf(Resolution.UHD), + displayProfiles = listOf(DisplayProfile.HDR10), + audioChannels = listOf(AudioChannel.CH_5_1), + audioCodecs = listOf(AudioCodec.EAC3), + isAtmos = listOf(false), + ), + writersString = "James Cameron, Laeta Kalogridis, Yukito Kishiro", + genresString = "Action, Science Fiction, Adventure", + videoString = "", + audioString = "", + subtitleString = "", + runTime = "121 min", + dateString = "2019", + ), + onPlayClick = {}, + onTrailerClick = {}, + onPlayedClick = {}, + onFavoriteClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt new file mode 100644 index 00000000..74ce8155 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/PlayerScreen.kt @@ -0,0 +1,297 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.ui.PlayerView +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import dev.jdtech.jellyfin.core.R +import dev.jdtech.jellyfin.destinations.VideoPlayerTrackSelectorDialogDestination +import dev.jdtech.jellyfin.models.PlayerItem +import dev.jdtech.jellyfin.models.Track +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerControlsLayout +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaButton +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerMediaTitle +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerOverlay +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerSeeker +import dev.jdtech.jellyfin.ui.components.player.VideoPlayerState +import dev.jdtech.jellyfin.ui.components.player.rememberVideoPlayerState +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.handleDPadKeyEvents +import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel +import kotlinx.coroutines.delay +import java.util.Locale +import kotlin.time.Duration.Companion.milliseconds + +@Destination +@Composable +fun PlayerScreen( + navigator: DestinationsNavigator, + items: ArrayList, + resultRecipient: ResultRecipient, +) { + val viewModel = hiltViewModel() + + val uiState by viewModel.uiState.collectAsState() + + val context = LocalContext.current + + var lifecycle by remember { + mutableStateOf(Lifecycle.Event.ON_CREATE) + } + var mediaSession by remember { + mutableStateOf(null) + } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + lifecycle = event + + // Handle creation and release of media session + when (lifecycle) { + Lifecycle.Event.ON_STOP -> { + println("ON_STOP") + mediaSession?.release() + } + + Lifecycle.Event.ON_START -> { + println("ON_START") + mediaSession = MediaSession.Builder(context, viewModel.player).build() + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val videoPlayerState = rememberVideoPlayerState() + + var currentPosition by remember { + mutableLongStateOf(0L) + } + var isPlaying by remember { + mutableStateOf(viewModel.player.isPlaying) + } + LaunchedEffect(Unit) { + while (true) { + delay(300) + currentPosition = viewModel.player.currentPosition + isPlaying = viewModel.player.isPlaying + } + } + + resultRecipient.onNavResult { result -> + when (result) { + is NavResult.Canceled -> Unit + is NavResult.Value -> { + viewModel.player.trackSelectionParameters = viewModel.player.trackSelectionParameters + .buildUpon() + .setOverrideForType( + TrackSelectionOverride(viewModel.player.currentTracks.groups[result.value].mediaTrackGroup, 0), + ) + .build() + } + } + } + + Box( + modifier = Modifier + .dPadEvents( + exoPlayer = viewModel.player, + videoPlayerState = videoPlayerState, + ) + .focusable(), + ) { + AndroidView( + factory = { context -> + PlayerView(context).also { playerView -> + playerView.player = viewModel.player + playerView.useController = false + viewModel.initializePlayer(items.toTypedArray()) + playerView.setBackgroundColor( + context.resources.getColor( + android.R.color.black, + context.theme, + ), + ) + } + }, + update = { + when (lifecycle) { + Lifecycle.Event.ON_PAUSE -> { + it.onPause() + it.player?.pause() + } + + Lifecycle.Event.ON_RESUME -> { + it.onResume() + } + + else -> Unit + } + }, + modifier = Modifier + .fillMaxSize(), + ) + val focusRequester = remember { FocusRequester() } + VideoPlayerOverlay( + modifier = Modifier.align(Alignment.BottomCenter), + focusRequester = focusRequester, + state = videoPlayerState, + isPlaying = isPlaying, + controls = { + VideoPlayerControls( + title = uiState.currentItemTitle, + isPlaying = isPlaying, + contentCurrentPosition = currentPosition, + player = viewModel.player, + state = videoPlayerState, + focusRequester = focusRequester, + navigator = navigator, + ) + }, + ) + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerControls( + title: String, + isPlaying: Boolean, + contentCurrentPosition: Long, + player: Player, + state: VideoPlayerState, + focusRequester: FocusRequester, + navigator: DestinationsNavigator, +) { + val onPlayPauseToggle = { shouldPlay: Boolean -> + if (shouldPlay) { + player.play() + } else { + player.pause() + } + } + + VideoPlayerControlsLayout( + mediaTitle = { + VideoPlayerMediaTitle( + title = title, + subtitle = null, + ) + }, + seeker = { + VideoPlayerSeeker( + focusRequester = focusRequester, + state = state, + isPlaying = isPlaying, + onPlayPauseToggle = onPlayPauseToggle, + onSeek = { player.seekTo(player.duration.times(it).toLong()) }, + contentProgress = contentCurrentPosition.milliseconds, + contentDuration = player.duration.milliseconds, + ) + }, + mediaActions = { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + ) { + VideoPlayerMediaButton( + icon = painterResource(id = R.drawable.ic_speaker), + state = state, + isPlaying = isPlaying, + onClick = { + val tracks = getTracks(player, C.TRACK_TYPE_AUDIO) + navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks)) + }, + ) + VideoPlayerMediaButton( + icon = painterResource(id = R.drawable.ic_closed_caption), + state = state, + isPlaying = isPlaying, + onClick = { + val tracks = getTracks(player, C.TRACK_TYPE_TEXT) + navigator.navigate(VideoPlayerTrackSelectorDialogDestination(tracks)) + }, + ) + } + }, + ) +} + +private fun Modifier.dPadEvents( + exoPlayer: Player, + videoPlayerState: VideoPlayerState, +): Modifier = this.handleDPadKeyEvents( + onLeft = {}, + onRight = {}, + onUp = {}, + onDown = {}, + onEnter = { + exoPlayer.pause() + videoPlayerState.showControls() + }, +) + +@androidx.annotation.OptIn(UnstableApi::class) +private fun getTracks(player: Player, type: Int): ArrayList { + val tracks = arrayListOf() + for (groupIndex in 0 until player.currentTracks.groups.count()) { + val group = player.currentTracks.groups[groupIndex] + if (group.type == type) { + val format = group.mediaTrackGroup.getFormat(0) + + val label = format.label + val language = Locale(format.language.toString()).displayLanguage + val codec = format.codecs + val selected = group.isSelected + + val track = Track( + id = groupIndex, + label = label, + language = language, + codec = codec, + selected = selected, + ) + + tracks.add(track) + } + } + return tracks +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt new file mode 100644 index 00000000..67fd9513 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SeasonScreen.kt @@ -0,0 +1,170 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.PlayerActivityDestination +import dev.jdtech.jellyfin.models.EpisodeItem +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.ui.components.EpisodeCard +import dev.jdtech.jellyfin.ui.dummy.dummyEpisodeItems +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import dev.jdtech.jellyfin.viewmodels.SeasonViewModel +import java.util.UUID + +@Destination +@Composable +fun SeasonScreen( + navigator: DestinationsNavigator, + seriesId: UUID, + seasonId: UUID, + seriesName: String, + seasonName: String, + seasonViewModel: SeasonViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), +) { + LaunchedEffect(true) { + seasonViewModel.loadEpisodes( + seriesId = seriesId, + seasonId = seasonId, + offline = false, + ) + } + + ObserveAsEvents(playerViewModel.eventsChannelFlow) { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> { + navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items))) + } + is PlayerItemsEvent.PlayerItemsError -> Unit + } + } + + val delegatedUiState by seasonViewModel.uiState.collectAsState() + + SeasonScreenLayout( + seriesName = seriesName, + seasonName = seasonName, + uiState = delegatedUiState, + onClick = { episode -> + playerViewModel.loadPlayerItems(item = episode) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SeasonScreenLayout( + seriesName: String, + seasonName: String, + uiState: SeasonViewModel.UiState, + onClick: (FindroidEpisode) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is SeasonViewModel.UiState.Loading -> Text(text = "LOADING") + is SeasonViewModel.UiState.Normal -> { + val episodes = uiState.episodes + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Row( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding( + start = MaterialTheme.spacings.extraLarge, + top = MaterialTheme.spacings.large, + end = MaterialTheme.spacings.large, + ), + ) { + Text( + text = seasonName, + style = MaterialTheme.typography.displayMedium, + ) + Text( + text = seriesName, + style = MaterialTheme.typography.headlineMedium, + ) + } + TvLazyColumn( + contentPadding = PaddingValues( + top = MaterialTheme.spacings.large, + bottom = MaterialTheme.spacings.large, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + modifier = Modifier + .weight(2f) + .padding(end = MaterialTheme.spacings.extraLarge) + .focusRequester(focusRequester), + ) { + items(episodes) { episodeItem -> + when (episodeItem) { + is EpisodeItem.Episode -> { + EpisodeCard(episode = episodeItem.episode, onClick = { onClick(episodeItem.episode) }) + } + + else -> Unit + } + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + } + } + is SeasonViewModel.UiState.Error -> Text(text = uiState.error.toString()) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun SeasonScreenLayoutPreview() { + FindroidTheme { + Surface { + SeasonScreenLayout( + seriesName = "86 EIGHTY-SIX", + seasonName = "Season 1", + uiState = SeasonViewModel.UiState.Normal(dummyEpisodeItems), + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt new file mode 100644 index 00000000..88cc7782 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ServerSelectScreen.kt @@ -0,0 +1,350 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.BorderStroke +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import dev.jdtech.jellyfin.NavGraphs +import dev.jdtech.jellyfin.destinations.AddServerScreenDestination +import dev.jdtech.jellyfin.destinations.MainScreenDestination +import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination +import dev.jdtech.jellyfin.models.DiscoveredServer +import dev.jdtech.jellyfin.models.Server +import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServer +import dev.jdtech.jellyfin.ui.dummy.dummyDiscoveredServers +import dev.jdtech.jellyfin.ui.dummy.dummyServers +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.ServerSelectEvent +import dev.jdtech.jellyfin.viewmodels.ServerSelectViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun ServerSelectScreen( + navigator: DestinationsNavigator, + serverSelectViewModel: ServerSelectViewModel = hiltViewModel(), +) { + val delegatedUiState by serverSelectViewModel.uiState.collectAsState() + val delegatedDiscoveredServersState by serverSelectViewModel.discoveredServersState.collectAsState() + + ObserveAsEvents(serverSelectViewModel.eventsChannelFlow) { event -> + when (event) { + ServerSelectEvent.NavigateToLogin -> { + navigator.navigate(UserSelectScreenDestination) + } + ServerSelectEvent.NavigateToHome -> { + navigator.navigate(MainScreenDestination) { + popUpTo(NavGraphs.root) { + inclusive = true + } + } + } + } + } + + ServerSelectScreenLayout( + uiState = delegatedUiState, + discoveredServersState = delegatedDiscoveredServersState, + onServerClick = { server -> + serverSelectViewModel.connectToServer( + Server( + id = server.id, + name = server.name, + currentUserId = null, + currentServerAddressId = null, + ), + ) + }, + onAddServerClick = { + navigator.navigate(AddServerScreenDestination) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ServerSelectScreenLayout( + uiState: ServerSelectViewModel.UiState, + discoveredServersState: ServerSelectViewModel.DiscoveredServersState, + onServerClick: (DiscoveredServer) -> Unit, + onAddServerClick: () -> Unit, +) { + var servers = emptyList() + var discoveredServers = emptyList() + + when (uiState) { + is ServerSelectViewModel.UiState.Normal -> { + servers = + uiState.servers.map { DiscoveredServer(id = it.id, name = it.name, address = "") } + } + + else -> Unit + } + when (discoveredServersState) { + is ServerSelectViewModel.DiscoveredServersState.Servers -> { + discoveredServers = discoveredServersState.servers + } + + else -> Unit + } + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + Text( + text = stringResource(id = CoreR.string.select_server), + style = MaterialTheme.typography.displayMedium, + ) + if (discoveredServers.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_sparkles), + contentDescription = null, + tint = Color(0xFFBDBDBD), + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall)) + Text( + text = pluralStringResource( + id = CoreR.plurals.discovered_servers, + count = discoveredServers.count(), + discoveredServers.count(), + ), + style = MaterialTheme.typography.titleMedium, + color = Color(0xFFBDBDBD), + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + if (servers.isEmpty() && discoveredServers.isEmpty()) { + Text( + text = stringResource(id = CoreR.string.no_servers_found), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(servers) { server -> + ServerComponent(server) { onServerClick(it) } + } + items(discoveredServers) { + ServerComponent(it, discovered = true) + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + OutlinedButton( + onClick = { onAddServerClick() }, + ) { + Text(text = stringResource(id = CoreR.string.add_server)) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun ServerSelectScreenLayoutPreview() { + FindroidTheme { + Surface { + ServerSelectScreenLayout( + uiState = ServerSelectViewModel.UiState.Normal(dummyServers), + discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers( + dummyDiscoveredServers, + ), + onServerClick = {}, + onAddServerClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun ServerSelectScreenLayoutPreviewNoDiscovered() { + FindroidTheme { + Surface { + ServerSelectScreenLayout( + uiState = ServerSelectViewModel.UiState.Normal(dummyServers), + discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers( + emptyList(), + ), + onServerClick = {}, + onAddServerClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun ServerSelectScreenLayoutPreviewNoServers() { + FindroidTheme { + Surface { + ServerSelectScreenLayout( + uiState = ServerSelectViewModel.UiState.Normal(emptyList()), + discoveredServersState = ServerSelectViewModel.DiscoveredServersState.Servers( + emptyList(), + ), + onServerClick = {}, + onAddServerClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ServerComponent( + server: DiscoveredServer, + discovered: Boolean = false, + onClick: (DiscoveredServer) -> Unit = {}, +) { + Surface( + onClick = { + onClick(server) + }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color(0xFF132026), + focusedContainerColor = Color(0xFF132026), + ), + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(16.dp), + ), + ), + modifier = Modifier + .width(270.dp) + .height(115.dp), + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (discovered) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_sparkles), + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(start = MaterialTheme.spacings.default / 2, top = MaterialTheme.spacings.default / 2), + ) + } + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxHeight() + .align(Alignment.Center) + .padding( + vertical = MaterialTheme.spacings.default, + horizontal = MaterialTheme.spacings.medium, + ), + ) { + Text( + text = server.name, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Text( + text = server.address, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFFBDBDBD), + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ServerComponentPreview() { + FindroidTheme { + Surface { + ServerComponent(dummyDiscoveredServer) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ServerComponentPreviewDiscovered() { + FindroidTheme { + Surface { + ServerComponent( + server = dummyDiscoveredServer, + discovered = true, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt new file mode 100644 index 00000000..cf873606 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsScreen.kt @@ -0,0 +1,169 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.grid.TvGridCells +import androidx.tv.foundation.lazy.grid.TvGridItemSpan +import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid +import androidx.tv.foundation.lazy.grid.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination +import dev.jdtech.jellyfin.destinations.SettingsSubScreenDestination +import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination +import dev.jdtech.jellyfin.models.Preference +import dev.jdtech.jellyfin.models.PreferenceCategory +import dev.jdtech.jellyfin.models.PreferenceSelect +import dev.jdtech.jellyfin.models.PreferenceSwitch +import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard +import dev.jdtech.jellyfin.ui.components.SettingsSelectCard +import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.SettingsEvent +import dev.jdtech.jellyfin.viewmodels.SettingsViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun SettingsScreen( + navigator: DestinationsNavigator, + settingsViewModel: SettingsViewModel = hiltViewModel(), +) { + LaunchedEffect(true) { + settingsViewModel.loadPreferences(intArrayOf()) + } + + ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event -> + when (event) { + is SettingsEvent.NavigateToSettings -> { + navigator.navigate(SettingsSubScreenDestination(event.indexes, event.title)) + } + is SettingsEvent.NavigateToUsers -> { + navigator.navigate(UserSelectScreenDestination) + } + is SettingsEvent.NavigateToServers -> { + navigator.navigate(ServerSelectScreenDestination) + } + } + } + + val delegatedUiState by settingsViewModel.uiState.collectAsState() + + SettingsScreenLayout(delegatedUiState) { preference -> + when (preference) { + is PreferenceSwitch -> { + settingsViewModel.setBoolean(preference.backendName, preference.value) + } + is PreferenceSelect -> { + settingsViewModel.setString(preference.backendName, preference.value) + } + } + settingsViewModel.loadPreferences(intArrayOf()) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SettingsScreenLayout( + uiState: SettingsViewModel.UiState, + onUpdate: (Preference) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is SettingsViewModel.UiState.Normal -> { + TvLazyVerticalGrid( + columns = TvGridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2, vertical = MaterialTheme.spacings.large), + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))) + .focusRequester(focusRequester), + ) { + item(span = { TvGridItemSpan(this.maxLineSpan) }) { + Text( + text = stringResource(id = CoreR.string.title_settings), + style = MaterialTheme.typography.displayMedium, + ) + } + items(uiState.preferences) { preference -> + when (preference) { + is PreferenceCategory -> SettingsCategoryCard(preference = preference) + is PreferenceSwitch -> { + SettingsSwitchCard(preference = preference) { + onUpdate(preference.copy(value = !preference.value)) + } + } + is PreferenceSelect -> { + val options = stringArrayResource(id = preference.options) + SettingsSelectCard(preference = preference) { + val currentIndex = options.indexOf(preference.value) + val newIndex = if (currentIndex == options.count() - 1) { + 0 + } else { + currentIndex + 1 + } + onUpdate(preference.copy(value = options[newIndex])) + } + } + } + } + } + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + is SettingsViewModel.UiState.Loading -> { + Text(text = "LOADING") + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun SettingsScreenLayoutPreview() { + FindroidTheme { + Surface { + SettingsScreenLayout( + uiState = SettingsViewModel.UiState.Normal( + listOf( + PreferenceCategory( + nameStringResource = CoreR.string.settings_category_language, + iconDrawableId = CoreR.drawable.ic_languages, + ), + PreferenceCategory( + nameStringResource = CoreR.string.settings_category_appearance, + iconDrawableId = CoreR.drawable.ic_palette, + ), + ), + ), + onUpdate = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt new file mode 100644 index 00000000..68a31cd2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/SettingsSubScreen.kt @@ -0,0 +1,249 @@ +package dev.jdtech.jellyfin.ui + +import androidx.annotation.StringRes +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.Constants +import dev.jdtech.jellyfin.destinations.ServerSelectScreenDestination +import dev.jdtech.jellyfin.destinations.SettingsScreenDestination +import dev.jdtech.jellyfin.destinations.UserSelectScreenDestination +import dev.jdtech.jellyfin.models.Preference +import dev.jdtech.jellyfin.models.PreferenceCategory +import dev.jdtech.jellyfin.models.PreferenceSelect +import dev.jdtech.jellyfin.models.PreferenceSwitch +import dev.jdtech.jellyfin.ui.components.SettingsCategoryCard +import dev.jdtech.jellyfin.ui.components.SettingsDetailsCard +import dev.jdtech.jellyfin.ui.components.SettingsSelectCard +import dev.jdtech.jellyfin.ui.components.SettingsSwitchCard +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.SettingsEvent +import dev.jdtech.jellyfin.viewmodels.SettingsViewModel +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun SettingsSubScreen( + indexes: IntArray = intArrayOf(), + @StringRes title: Int, + navigator: DestinationsNavigator, + settingsViewModel: SettingsViewModel = hiltViewModel(), +) { + LaunchedEffect(true) { + settingsViewModel.loadPreferences(indexes) + } + + ObserveAsEvents(settingsViewModel.eventsChannelFlow) { event -> + when (event) { + is SettingsEvent.NavigateToSettings -> { + navigator.navigate(SettingsScreenDestination) + } + is SettingsEvent.NavigateToUsers -> { + navigator.navigate(UserSelectScreenDestination) + } + is SettingsEvent.NavigateToServers -> { + navigator.navigate(ServerSelectScreenDestination) + } + } + } + + val delegatedUiState by settingsViewModel.uiState.collectAsState() + + SettingsSubScreenLayout(delegatedUiState, title) { preference -> + when (preference) { + is PreferenceSwitch -> { + settingsViewModel.setBoolean(preference.backendName, preference.value) + } + is PreferenceSelect -> { + settingsViewModel.setString(preference.backendName, preference.value) + } + } + settingsViewModel.loadPreferences(indexes) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun SettingsSubScreenLayout( + uiState: SettingsViewModel.UiState, + @StringRes title: Int? = null, + onUpdate: (Preference) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + when (uiState) { + is SettingsViewModel.UiState.Normal -> { + var focusedPreference by remember { + mutableStateOf(uiState.preferences.first()) + } + Column( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))) + .padding( + start = MaterialTheme.spacings.large, + top = MaterialTheme.spacings.default * 2, + end = MaterialTheme.spacings.large, + ), + ) { + if (title != null) { + Column { + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.displayMedium, + ) + Text( + text = stringResource(id = CoreR.string.title_settings), + style = MaterialTheme.typography.headlineMedium, + ) + } + } else { + Text( + text = stringResource(id = CoreR.string.title_settings), + style = MaterialTheme.typography.displayMedium, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + ) { + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(vertical = MaterialTheme.spacings.large), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + ) { + items(uiState.preferences) { preference -> + when (preference) { + is PreferenceCategory -> SettingsCategoryCard( + preference = preference, + modifier = Modifier.onFocusChanged { + if (it.isFocused) { + focusedPreference = preference + } + }, + ) + is PreferenceSwitch -> { + SettingsSwitchCard( + preference = preference, + modifier = Modifier.onFocusChanged { + if (it.isFocused) { + focusedPreference = preference + } + }, + ) { + onUpdate(preference.copy(value = !preference.value)) + } + } + is PreferenceSelect -> { + val optionValues = stringArrayResource(id = preference.optionValues) + SettingsSelectCard( + preference = preference, + modifier = Modifier.onFocusChanged { + if (it.isFocused) { + focusedPreference = preference + } + }, + ) { + val currentIndex = optionValues.indexOf(preference.value) + val newIndex = if (currentIndex == optionValues.count() - 1) { + 0 + } else { + currentIndex + 1 + } + val newPreference = preference.copy(value = optionValues[newIndex]) + onUpdate(newPreference) + if (focusedPreference == preference) { + focusedPreference = newPreference + } + } + } + } + } + } + Box( + modifier = Modifier.weight(2f), + ) { + (focusedPreference as? PreferenceSelect)?.let { + SettingsDetailsCard( + preference = it, + modifier = Modifier + .fillMaxSize() + .padding(bottom = MaterialTheme.spacings.large), + onOptionSelected = { value -> + println(value) + val newPreference = it.copy(value = value) + onUpdate(newPreference) + focusedPreference = newPreference + }, + ) + } + } + } + } + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + is SettingsViewModel.UiState.Loading -> { + Text(text = "LOADING") + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun SettingsSubScreenLayoutPreview() { + FindroidTheme { + Surface { + SettingsSubScreenLayout( + uiState = SettingsViewModel.UiState.Normal( + listOf( + PreferenceSelect( + nameStringResource = CoreR.string.pref_player_mpv_hwdec, + backendName = Constants.PREF_PLAYER_MPV_HWDEC, + backendDefaultValue = "mediacodec", + options = CoreR.array.mpv_hwdec, + optionValues = CoreR.array.mpv_hwdec, + ), + ), + ), + title = CoreR.string.settings_category_player, + onUpdate = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt new file mode 100644 index 00000000..9c77e3c8 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/ShowScreen.kt @@ -0,0 +1,424 @@ +package dev.jdtech.jellyfin.ui + +import android.content.Intent +import android.net.Uri +import android.view.KeyEvent +import android.widget.Toast +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.foundation.lazy.list.rememberTvLazyListState +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import dev.jdtech.jellyfin.destinations.PlayerActivityDestination +import dev.jdtech.jellyfin.destinations.SeasonScreenDestination +import dev.jdtech.jellyfin.models.FindroidSeason +import dev.jdtech.jellyfin.ui.components.Direction +import dev.jdtech.jellyfin.ui.components.ItemCard +import dev.jdtech.jellyfin.ui.dummy.dummyShow +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.Yellow +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.PlayerItemsEvent +import dev.jdtech.jellyfin.viewmodels.PlayerViewModel +import dev.jdtech.jellyfin.viewmodels.ShowViewModel +import java.util.UUID +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun ShowScreen( + navigator: DestinationsNavigator, + itemId: UUID, + showViewModel: ShowViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), +) { + val context = LocalContext.current + LaunchedEffect(key1 = true) { + showViewModel.loadData(itemId, false) + } + + ObserveAsEvents(playerViewModel.eventsChannelFlow) { event -> + when (event) { + is PlayerItemsEvent.PlayerItemsReady -> { + navigator.navigate(PlayerActivityDestination(items = ArrayList(event.items))) + } + is PlayerItemsEvent.PlayerItemsError -> Unit + } + } + + val delegatedUiState by showViewModel.uiState.collectAsState() + + ShowScreenLayout( + uiState = delegatedUiState, + onPlayClick = { + playerViewModel.loadPlayerItems(showViewModel.item) + }, + onTrailerClick = { trailerUri -> + try { + Intent( + Intent.ACTION_VIEW, + Uri.parse(trailerUri), + ).also { + context.startActivity(it) + } + } catch (e: Exception) { + Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() + } + }, + onPlayedClick = { + showViewModel.togglePlayed() + }, + onFavoriteClick = { + showViewModel.toggleFavorite() + }, + onSeasonClick = { season -> + navigator.navigate(SeasonScreenDestination(seriesId = season.seriesId, seasonId = season.id, seriesName = season.seriesName, seasonName = season.name)) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ShowScreenLayout( + uiState: ShowViewModel.UiState, + onPlayClick: () -> Unit, + onTrailerClick: (String) -> Unit, + onPlayedClick: () -> Unit, + onFavoriteClick: () -> Unit, + onSeasonClick: (FindroidSeason) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + val listState = rememberTvLazyListState() + val listSize = remember { mutableIntStateOf(2) } + var currentIndex by remember { mutableIntStateOf(0) } + + LaunchedEffect(currentIndex) { + listState.animateScrollToItem(currentIndex) + } + + when (uiState) { + is ShowViewModel.UiState.Loading -> Text(text = "LOADING") + is ShowViewModel.UiState.Normal -> { + val item = uiState.item + val seasons = uiState.seasons + var size by remember { + mutableStateOf(Size.Zero) + } + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + size = coordinates.size.toSize() + }, + ) { + AsyncImage( + model = item.images.backdrop, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize(), + ) + if (size != Size.Zero) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + listOf(Color.Black.copy(alpha = .2f), Color.Black), + center = Offset(size.width, 0f), + radius = size.width * .8f, + ), + ), + ) + } + TvLazyColumn( + state = listState, + contentPadding = PaddingValues(top = 112.dp, bottom = MaterialTheme.spacings.large), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + userScrollEnabled = false, + modifier = Modifier.onPreviewKeyEvent { keyEvent -> + when (keyEvent.key.nativeKeyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + currentIndex = (++currentIndex).coerceIn(0, listSize.intValue - 1) + } + KeyEvent.KEYCODE_DPAD_UP -> { + currentIndex = (--currentIndex).coerceIn(0, listSize.intValue - 1) + } + } + false + }, + ) { + item { + Column( + modifier = Modifier + .padding( + start = MaterialTheme.spacings.default * 2, + end = MaterialTheme.spacings.default * 2, + ), + ) { + Text( + text = item.name, + style = MaterialTheme.typography.displayMedium, + ) + if (item.originalTitle != item.name) { + item.originalTitle?.let { originalTitle -> + Text( + text = originalTitle, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.small)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small), + ) { + Text( + text = uiState.dateString, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = uiState.runTime, + style = MaterialTheme.typography.labelMedium, + ) + item.officialRating?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + ) + } + item.communityRating?.let { + Row { + Icon( + painter = painterResource(id = CoreR.drawable.ic_star), + contentDescription = null, + tint = Yellow, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.extraSmall)) + Text( + text = String.format("%.1f", item.communityRating), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + Text( + text = item.overview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(640.dp), + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium), + ) { + Button( + onClick = { + onPlayClick() + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_play), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = CoreR.string.play)) + } + item.trailer?.let { trailerUri -> + Button( + onClick = { + onTrailerClick(trailerUri) + }, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_film), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = CoreR.string.watch_trailer)) + } + } + Button( + onClick = { + onPlayedClick() + }, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_check), + contentDescription = null, + tint = if (item.played) Color.Red else LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = if (item.played) CoreR.string.unmark_as_played else CoreR.string.mark_as_played)) + } + Button( + onClick = { + onFavoriteClick() + }, + ) { + Icon( + painter = painterResource(id = if (item.favorite) CoreR.drawable.ic_heart_filled else CoreR.drawable.ic_heart), + contentDescription = null, + tint = if (item.favorite) Color.Red else LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(id = if (item.favorite) CoreR.string.remove_from_favorites else CoreR.string.add_to_favorites)) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.large), + ) { + Column { + Text( + text = stringResource(id = CoreR.string.genres), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = uiState.genresString, + style = MaterialTheme.typography.bodyMedium, + ) + } + uiState.director?.let { director -> + Column { + Text( + text = stringResource(id = CoreR.string.director), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = director.name ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Column { + Text( + text = stringResource(id = CoreR.string.writers), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = .5f), + ) + Text( + text = uiState.writersString, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + Text( + text = stringResource(id = CoreR.string.seasons), + style = MaterialTheme.typography.headlineMedium, + ) + } + } + item { + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacings.default * 2), + ) { + items(seasons) { season -> + ItemCard( + item = season, + direction = Direction.VERTICAL, + onClick = { + onSeasonClick(season) + }, + ) + } + } + } + } + } + + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + + is ShowViewModel.UiState.Error -> Text(text = uiState.error.toString()) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun ShowScreenLayoutPreview() { + FindroidTheme { + Surface { + ShowScreenLayout( + uiState = ShowViewModel.UiState.Normal( + item = dummyShow, + actors = emptyList(), + director = null, + writers = emptyList(), + writersString = "Hiroshi Seko, Hajime Isayama", + genresString = "Action, Science Fiction, Adventure", + runTime = "0 min", + dateString = "2013 - 2023", + nextUp = null, + seasons = emptyList(), + ), + onPlayClick = {}, + onTrailerClick = {}, + onPlayedClick = {}, + onFavoriteClick = {}, + onSeasonClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt new file mode 100644 index 00000000..6abdd54b --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/UserSelectScreen.kt @@ -0,0 +1,288 @@ +package dev.jdtech.jellyfin.ui + +import androidx.compose.foundation.BorderStroke +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.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import dev.jdtech.jellyfin.NavGraphs +import dev.jdtech.jellyfin.api.JellyfinApi +import dev.jdtech.jellyfin.destinations.LoginScreenDestination +import dev.jdtech.jellyfin.destinations.MainScreenDestination +import dev.jdtech.jellyfin.models.Server +import dev.jdtech.jellyfin.models.User +import dev.jdtech.jellyfin.ui.dummy.dummyServer +import dev.jdtech.jellyfin.ui.dummy.dummyUser +import dev.jdtech.jellyfin.ui.dummy.dummyUsers +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.utils.ObserveAsEvents +import dev.jdtech.jellyfin.viewmodels.UserSelectEvent +import dev.jdtech.jellyfin.viewmodels.UserSelectViewModel +import org.jellyfin.sdk.model.api.ImageType +import dev.jdtech.jellyfin.core.R as CoreR + +@Destination +@Composable +fun UserSelectScreen( + navigator: DestinationsNavigator, + userSelectViewModel: UserSelectViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val api = JellyfinApi.getInstance(context) + val delegatedUiState by userSelectViewModel.uiState.collectAsState() + + ObserveAsEvents(userSelectViewModel.eventsChannelFlow) { event -> + when (event) { + is UserSelectEvent.NavigateToMain -> { + navigator.navigate(MainScreenDestination) { + popUpTo(NavGraphs.root) { + inclusive = true + } + } + } + } + } + + LaunchedEffect(key1 = true) { + userSelectViewModel.loadUsers() + } + + UserSelectScreenLayout( + uiState = delegatedUiState, + baseUrl = api.api.baseUrl ?: "", + onUserClick = { user -> + userSelectViewModel.loginAsUser(user) + }, + onAddUserClick = { + navigator.navigate(LoginScreenDestination) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun UserSelectScreenLayout( + uiState: UserSelectViewModel.UiState, + baseUrl: String, + onUserClick: (User) -> Unit, + onAddUserClick: () -> Unit, +) { + var server: Server? = null + var users: List = emptyList() + + when (uiState) { + is UserSelectViewModel.UiState.Normal -> { + server = uiState.server + users = uiState.users + } + else -> Unit + } + + val focusRequester = remember { FocusRequester() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.linearGradient(listOf(Color.Black, Color(0xFF001721)))), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) { + Text( + text = stringResource(id = CoreR.string.select_user), + style = MaterialTheme.typography.displayMedium, + ) + server?.let { + Text( + text = "Server: ${it.name}", + style = MaterialTheme.typography.titleMedium, + color = Color(0xFFBDBDBD), + ) + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + if (users.isEmpty()) { + Text( + text = stringResource(id = CoreR.string.no_users_found), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.default), + contentPadding = PaddingValues(MaterialTheme.spacings.default), + modifier = Modifier.focusRequester(focusRequester), + ) { + items(users) { + UserComponent( + user = it, + baseUrl = baseUrl, + ) { user -> + onUserClick(user) + } + } + } + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.large)) + OutlinedButton( + onClick = { + onAddUserClick() + }, + ) { + Text(text = stringResource(id = CoreR.string.add_user)) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun UserSelectScreenLayoutPreview() { + FindroidTheme { + Surface { + UserSelectScreenLayout( + uiState = UserSelectViewModel.UiState.Normal(dummyServer, dummyUsers), + baseUrl = "https://demo.jellyfin.org/stable", + onUserClick = {}, + onAddUserClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview(widthDp = 960, heightDp = 540) +@Composable +private fun UserSelectScreenLayoutPreviewNoUsers() { + FindroidTheme { + Surface { + UserSelectScreenLayout( + uiState = UserSelectViewModel.UiState.Normal(dummyServer, emptyList()), + baseUrl = "https://demo.jellyfin.org/stable", + onUserClick = {}, + onAddUserClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun UserComponent( + user: User, + baseUrl: String, + onClick: (User) -> Unit = {}, +) { + val context = LocalContext.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(120.dp), + ) { + Surface( + onClick = { + onClick(user) + }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape), + focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape), + ), + shape = ClickableSurfaceDefaults.shape( + shape = CircleShape, + focusedShape = CircleShape, + ), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_user), + contentDescription = null, + tint = Color.White, + modifier = Modifier + .width(48.dp) + .height(48.dp) + .align(Alignment.Center), + ) + AsyncImage( + model = ImageRequest.Builder(context) + .data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}") + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + Text( + text = user.name, + style = MaterialTheme.typography.titleMedium, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun UserComponentPreview() { + FindroidTheme { + Surface { + UserComponent( + user = dummyUser, + baseUrl = "https://demo.jellyfin.org/stable", + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/Banner.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/Banner.kt new file mode 100644 index 00000000..4ecde59e --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/Banner.kt @@ -0,0 +1,24 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import dev.jdtech.jellyfin.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +fun Banner() { + Icon( + painter = painterResource(id = R.drawable.ic_banner), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.width(320.dp), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt new file mode 100644 index 00000000..3a84f791 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/EpisodeCard.kt @@ -0,0 +1,113 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.ui.dummy.dummyEpisode +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun EpisodeCard( + episode: FindroidEpisode, + onClick: (FindroidEpisode) -> Unit, +) { + Surface( + onClick = { onClick(episode) }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + modifier = Modifier + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(MaterialTheme.spacings.small), + ) { + Box(modifier = Modifier.width(160.dp)) { + ItemPoster( + item = episode, + direction = Direction.HORIZONTAL, + modifier = Modifier.clip(RoundedCornerShape(10.dp)), + ) + ProgressBadge( + item = episode, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(PaddingValues(MaterialTheme.spacings.small)), + ) + } + Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium)) + Column { + Text( + text = stringResource( + id = dev.jdtech.jellyfin.core.R.string.episode_name, + episode.indexNumber, + episode.name, + ), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall)) + Text( + text = episode.overview, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ItemCardPreviewEpisode() { + FindroidTheme { + Surface { + EpisodeCard( + episode = dummyEpisode, + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt new file mode 100644 index 00000000..d2f632ad --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemCard.kt @@ -0,0 +1,173 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.core.R +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.ui.dummy.dummyEpisode +import dev.jdtech.jellyfin.ui.dummy.dummyMovie +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ItemCard( + item: FindroidItem, + direction: Direction, + onClick: (FindroidItem) -> Unit, + modifier: Modifier = Modifier, +) { + val width = when (direction) { + Direction.HORIZONTAL -> 260 + Direction.VERTICAL -> 150 + } + Column( + modifier = modifier + .width(width.dp), + ) { + Surface( + onClick = { onClick(item) }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + ) { + Box { + ItemPoster( + item = item, + direction = direction, + ) + ProgressBadge( + item = item, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(MaterialTheme.spacings.small), + ) + if (direction == Direction.HORIZONTAL) { + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(MaterialTheme.spacings.small), + ) { + Box( + modifier = Modifier + .height(4.dp) + .width( + item.playbackPositionTicks + .div( + item.runtimeTicks.toFloat(), + ) + .times( + width - 16, + ).dp, + ) + .clip( + MaterialTheme.shapes.extraSmall, + ) + .background( + MaterialTheme.colorScheme.primary, + ), + ) + } + } + } + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.small)) + Text( + text = if (item is FindroidEpisode) item.seriesName else item.name, + style = MaterialTheme.typography.titleMedium, + maxLines = if (direction == Direction.HORIZONTAL) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (item is FindroidEpisode) { + Text( + text = stringResource( + id = R.string.episode_name_extended, + item.parentIndexNumber, + item.indexNumber, + item.name, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ItemCardPreviewMovie() { + FindroidTheme { + Surface { + ItemCard( + item = dummyMovie, + direction = Direction.HORIZONTAL, + onClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ItemCardPreviewMovieVertical() { + FindroidTheme { + Surface { + ItemCard( + item = dummyMovie, + direction = Direction.VERTICAL, + onClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ItemCardPreviewEpisode() { + FindroidTheme { + Surface { + ItemCard( + item = dummyEpisode, + direction = Direction.HORIZONTAL, + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt new file mode 100644 index 00000000..623bed32 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ItemPoster.kt @@ -0,0 +1,51 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import coil.compose.AsyncImage +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.models.FindroidMovie + +enum class Direction { + HORIZONTAL, VERTICAL +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ItemPoster( + item: FindroidItem, + direction: Direction, + modifier: Modifier = Modifier, +) { + var imageUri = item.images.primary + + when (direction) { + Direction.HORIZONTAL -> { + if (item is FindroidMovie) imageUri = item.images.backdrop + } + Direction.VERTICAL -> { + when (item) { + is FindroidEpisode -> imageUri = item.images.showPrimary + } + } + } + + AsyncImage( + model = imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .aspectRatio(if (direction == Direction.HORIZONTAL) 1.77f else 0.66f) + .background( + MaterialTheme.colorScheme.surface, + ), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt new file mode 100644 index 00000000..1932c17c --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/LoadingIndicator.kt @@ -0,0 +1,18 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingIndicator() { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 2.dp, + trackColor = Color.Transparent, + modifier = Modifier.size(32.dp), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt new file mode 100644 index 00000000..1cfc1958 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/PillBorderIndicator.kt @@ -0,0 +1,74 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import androidx.compose.ui.zIndex +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.TabRow + +/** + * Adds a pill shaped border indicator behind the tab + * + * @param currentTabPosition position of the current selected tab + * @param doesTabRowHaveFocus whether any tab in TabRow is focused + * @param modifier modifier to be applied to the indicator + * @param activeBorderColor color of border when [TabRow] is active + * @param inactiveBorderColor color of border when [TabRow] is inactive + * + * This component is adapted from androidx.tv.material3.TabRowDefaults.PillIndicator + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun PillBorderIndicator( + currentTabPosition: DpRect, + doesTabRowHaveFocus: Boolean, + modifier: Modifier = Modifier, + activeBorderColor: Color = MaterialTheme.colorScheme.onSurface, + inactiveBorderColor: Color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f), +) { + val width by animateDpAsState( + targetValue = currentTabPosition.width, + label = "PillIndicator.width", + ) + val height = currentTabPosition.height + val leftOffset by animateDpAsState( + targetValue = currentTabPosition.left, + label = "PillIndicator.leftOffset", + ) + val topOffset = currentTabPosition.top + + val borderColor by + animateColorAsState( + targetValue = if (doesTabRowHaveFocus) activeBorderColor else inactiveBorderColor, + label = "PillIndicator.pillColor", + ) + + Box( + modifier + .fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = leftOffset, y = topOffset) + .width(width) + .height(height) + .border(width = 4.dp, color = borderColor, shape = RoundedCornerShape(50)) + .zIndex(-1f), + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt new file mode 100644 index 00000000..cfadb750 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProfileButton.kt @@ -0,0 +1,96 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.Surface +import coil.compose.AsyncImage +import coil.request.ImageRequest +import dev.jdtech.jellyfin.api.JellyfinApi +import dev.jdtech.jellyfin.core.R +import dev.jdtech.jellyfin.models.User +import dev.jdtech.jellyfin.ui.dummy.dummyUser +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import org.jellyfin.sdk.model.api.ImageType + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ProfileButton( + user: User?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val baseUrl = JellyfinApi.getInstance(context).api.baseUrl + Surface( + onClick = { + onClick() + }, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + border = ClickableSurfaceDefaults.border( + border = Border(BorderStroke(1.dp, Color.White), shape = CircleShape), + focusedBorder = Border(BorderStroke(4.dp, Color.White), shape = CircleShape), + ), + shape = ClickableSurfaceDefaults.shape( + shape = CircleShape, + focusedShape = CircleShape, + ), + modifier = modifier + .width(32.dp) + .aspectRatio(1f), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_user), + contentDescription = null, + tint = Color.White, + modifier = Modifier + .width(16.dp) + .height(16.dp) + .align(Alignment.Center), + ) + user?.let { + AsyncImage( + model = ImageRequest.Builder(context) + .data("$baseUrl/users/${user.id}/Images/${ImageType.PRIMARY}") + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ProfileButtonPreview() { + FindroidTheme { + Surface { + ProfileButton( + user = dummyUser, + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt new file mode 100644 index 00000000..aa67c008 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/ProgressBadge.kt @@ -0,0 +1,94 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.models.FindroidItem +import dev.jdtech.jellyfin.ui.dummy.dummyEpisode +import dev.jdtech.jellyfin.ui.dummy.dummyShow +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.core.R as CoreR + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ProgressBadge( + item: FindroidItem, + modifier: Modifier = Modifier, +) { + if (!(!item.played && item.unplayedItemCount == null)) { + Box( + modifier = modifier + .height(24.dp) + .defaultMinSize(24.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary), + ) { + when (item.played) { + true -> { + Icon( + painter = painterResource(id = CoreR.drawable.ic_check), + contentDescription = "", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + ) + } + + false -> { + Text( + text = item.unplayedItemCount.toString(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = MaterialTheme.spacings.extraSmall), + ) + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ProgressBadgePreviewWatched() { + FindroidTheme { + Surface { + ProgressBadge( + item = dummyEpisode, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun ProgressBadgePreviewItemRemaining() { + FindroidTheme { + Surface { + ProgressBadge( + item = dummyShow, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt new file mode 100644 index 00000000..e383ed73 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsCategoryCard.kt @@ -0,0 +1,110 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.models.PreferenceCategory +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.core.R as CoreR + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsCategoryCard( + preference: PreferenceCategory, + modifier: Modifier = Modifier, +) { + Surface( + onClick = { + preference.onClick(preference) + }, + enabled = preference.enabled, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + modifier = modifier + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(MaterialTheme.spacings.default), + verticalAlignment = Alignment.CenterVertically, + ) { + if (preference.iconDrawableId != null) { + Icon( + painter = painterResource(id = preference.iconDrawableId!!), + contentDescription = null, + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.width(24.dp)) + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(id = preference.nameStringResource), + style = MaterialTheme.typography.titleMedium, + ) + preference.descriptionStringRes?.let { + Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall)) + Text( + text = stringResource(id = it), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun SettingsCategoryCardPreview() { + FindroidTheme { + Surface { + SettingsCategoryCard( + preference = PreferenceCategory( + nameStringResource = CoreR.string.settings_category_player, + iconDrawableId = CoreR.drawable.ic_play, + ), + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt new file mode 100644 index 00000000..94ff0ed3 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsDetailsSelectCard.kt @@ -0,0 +1,118 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.Constants +import dev.jdtech.jellyfin.models.PreferenceSelect +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.core.R as CoreR + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsDetailsCard( + preference: PreferenceSelect, + modifier: Modifier = Modifier, + onOptionSelected: (String) -> Unit, +) { + val options = stringArrayResource(id = preference.options) + val optionValues = stringArrayResource(id = preference.optionValues) + + Surface( + modifier = modifier, + ) { + Column( + modifier = Modifier.padding( + horizontal = MaterialTheme.spacings.default, + vertical = MaterialTheme.spacings.medium, + ), + ) { + Text(text = stringResource(id = preference.nameStringResource), style = MaterialTheme.typography.headlineMedium) + preference.descriptionStringRes?.let { + Spacer(modifier = Modifier.height(MaterialTheme.spacings.small)) + Text(text = stringResource(id = it), style = MaterialTheme.typography.bodyMedium) + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.default)) + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall), + contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall), + ) { + items(optionValues.count()) { optionIndex -> + Surface( + onClick = { onOptionSelected(optionValues[optionIndex]) }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(MaterialTheme.spacings.extraSmall), + ) { + RadioButton( + selected = preference.value == optionValues[optionIndex], + onClick = null, + enabled = preference.enabled, + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium)) + Text(text = options[optionIndex], style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun SettingsDetailCardPreview() { + FindroidTheme { + SettingsDetailsCard( + preference = PreferenceSelect( + nameStringResource = CoreR.string.settings_preferred_audio_language, + backendName = Constants.PREF_AUDIO_LANGUAGE, + backendDefaultValue = null, + options = CoreR.array.languages, + optionValues = CoreR.array.languages_values, + ), + onOptionSelected = {}, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt new file mode 100644 index 00000000..e803a489 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSelectCard.kt @@ -0,0 +1,124 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.Constants +import dev.jdtech.jellyfin.models.PreferenceSelect +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import dev.jdtech.jellyfin.core.R as CoreR + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsSelectCard( + preference: PreferenceSelect, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val options = stringArrayResource(id = preference.options) + val optionValues = stringArrayResource(id = preference.optionValues) + + Surface( + onClick = onClick, + enabled = preference.enabled, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + modifier = modifier + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(MaterialTheme.spacings.default), + verticalAlignment = Alignment.CenterVertically, + ) { + if (preference.iconDrawableId != null) { + Icon( + painter = painterResource(id = preference.iconDrawableId!!), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(24.dp)) + } + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(id = preference.nameStringResource), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall)) + Text( + text = if (preference.value != null) { + val index = optionValues.indexOf(preference.value) + if (index == -1) { + "Unknown" + } else { + options[index] + } + } else { + "Not set" + }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun SettingsSelectCardPreview() { + FindroidTheme { + Surface { + SettingsSelectCard( + preference = PreferenceSelect( + nameStringResource = CoreR.string.settings_preferred_audio_language, + iconDrawableId = CoreR.drawable.ic_speaker, + backendName = Constants.PREF_AUDIO_LANGUAGE, + backendDefaultValue = null, + options = CoreR.array.languages, + optionValues = CoreR.array.languages_values, + ), + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt new file mode 100644 index 00000000..8c16459d --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/SettingsSwitchCard.kt @@ -0,0 +1,159 @@ +package dev.jdtech.jellyfin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.core.R +import dev.jdtech.jellyfin.models.PreferenceSwitch +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun SettingsSwitchCard( + preference: PreferenceSwitch, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + enabled = preference.enabled, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + modifier = modifier + .fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(MaterialTheme.spacings.default), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.small), + ) { + if (preference.iconDrawableId != null) { + Icon( + painter = painterResource(id = preference.iconDrawableId!!), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(id = preference.nameStringResource), + style = MaterialTheme.typography.titleMedium, + ) + preference.descriptionStringRes?.let { + Spacer(modifier = Modifier.height(MaterialTheme.spacings.extraSmall)) + Text( + text = stringResource(id = it), + style = MaterialTheme.typography.labelMedium, + ) + } + } + + Switch( + checked = preference.value, + onCheckedChange = null, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun SettingsSwitchCardPreview() { + FindroidTheme { + Surface { + SettingsSwitchCard( + preference = PreferenceSwitch( + nameStringResource = R.string.settings_use_cache_title, + iconDrawableId = null, + backendName = "image-cache", + backendDefaultValue = false, + value = false, + ), + onClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun SettingsSwitchCardDisabledPreview() { + FindroidTheme { + Surface { + SettingsSwitchCard( + preference = PreferenceSwitch( + nameStringResource = R.string.settings_use_cache_title, + iconDrawableId = null, + enabled = false, + backendName = "image-cache", + backendDefaultValue = false, + value = false, + ), + onClick = {}, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Preview +@Composable +private fun SettingsSwitchCardDescriptionPreview() { + FindroidTheme { + Surface { + SettingsSwitchCard( + preference = PreferenceSwitch( + nameStringResource = R.string.settings_use_cache_title, + descriptionStringRes = R.string.settings_use_cache_summary, + iconDrawableId = null, + backendName = "image-cache", + backendDefaultValue = true, + value = true, + ), + onClick = {}, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt new file mode 100644 index 00000000..e8717d28 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerControls.kt @@ -0,0 +1,82 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerControlsLayout( + mediaTitle: @Composable () -> Unit, + seeker: @Composable () -> Unit, + mediaActions: @Composable () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Box(modifier = Modifier.weight(1f)) { + mediaTitle() + } + mediaActions() + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + seeker() + } +} + +@Preview +@Composable +private fun VideoPlayerControlsLayoutPreview() { + FindroidTheme { + VideoPlayerControlsLayout( + mediaTitle = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(96.dp), + ) + }, + seeker = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(48.dp), + ) + }, + mediaActions = { + Box( + Modifier + .border(2.dp, Color.Red) + .background(Color.LightGray) + .fillMaxWidth() + .height(48.dp), + ) + }, + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt new file mode 100644 index 00000000..73139a13 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaButton.kt @@ -0,0 +1,34 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerMediaButton( + icon: Painter, + state: VideoPlayerState, + isPlaying: Boolean, + onClick: () -> Unit = {}, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + LaunchedEffect(isFocused && isPlaying) { + if (isFocused && isPlaying) { + state.showControls() + } + } + + IconButton(onClick = onClick, interactionSource = interactionSource) { + Icon(painter = icon, contentDescription = null) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt new file mode 100644 index 00000000..e16bb849 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerMediaTitle.kt @@ -0,0 +1,43 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.ui.theme.FindroidTheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerMediaTitle( + title: String, + subtitle: String?, +) { + Column { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.titleMedium, + color = Color.White.copy(alpha = .75f), + ) + } + } +} + +@Preview +@Composable +private fun VideoPlayerMediaTitlePreview() { + FindroidTheme { + VideoPlayerMediaTitle( + title = "S1:E23 - Handler One", + subtitle = "86 EIGHTY-SIX", + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt new file mode 100644 index 00000000..86d536d2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerOverlay.kt @@ -0,0 +1,102 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerOverlay( + isPlaying: Boolean, + modifier: Modifier = Modifier, + state: VideoPlayerState = rememberVideoPlayerState(), + focusRequester: FocusRequester = remember { FocusRequester() }, + controls: @Composable () -> Unit = {}, +) { + LaunchedEffect(state.controlsVisible) { + if (state.controlsVisible) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(isPlaying) { + if (!isPlaying) { + state.showControls(seconds = Int.MAX_VALUE) + } else { + state.showControls() + } + } + + AnimatedVisibility( + visible = state.controlsVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Spacer( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.8f), + ), + ), + ), + ) + + Column( + Modifier.padding(MaterialTheme.spacings.default * 2), + ) { + controls() + } + } + } +} + +@Preview(device = "id:tv_4k") +@Composable +private fun VideoPlayerOverlayPreview() { + FindroidTheme { + Box(Modifier.fillMaxSize()) { + VideoPlayerOverlay( + modifier = Modifier.align(Alignment.BottomCenter), + isPlaying = true, + controls = { + Box( + Modifier + .fillMaxWidth() + .height(120.dp) + .background(Color.Blue), + ) + }, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt new file mode 100644 index 00000000..e98685f5 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeekBar.kt @@ -0,0 +1,133 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.utils.handleDPadKeyEvents + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun VideoPlayerSeekBar( + progress: Float, + onSeek: (seekProgress: Float) -> Unit, + state: VideoPlayerState, +) { + val interactionSource = remember { MutableInteractionSource() } + var isSelected by remember { mutableStateOf(false) } + val isFocused by interactionSource.collectIsFocusedAsState() + val color by rememberUpdatedState( + newValue = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + val animatedHeight by animateDpAsState( + targetValue = 8.dp.times(if (isFocused) 2f else 1f), + ) + var seekProgress by remember { mutableFloatStateOf(0f) } + val focusManager = LocalFocusManager.current + + LaunchedEffect(isSelected) { + if (isSelected) { + state.showControls(seconds = Int.MAX_VALUE) + } + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(animatedHeight) + .padding(horizontal = 4.dp) + .handleDPadKeyEvents( + onEnter = { + if (isSelected) { + onSeek(seekProgress) + focusManager.moveFocus(FocusDirection.Exit) + } else { + seekProgress = progress + } + isSelected = !isSelected + }, + onLeft = { + if (isSelected) { + seekProgress = (seekProgress - 0.05f).coerceAtLeast(0f) + } else { + focusManager.moveFocus(FocusDirection.Left) + } + }, + onRight = { + if (isSelected) { + seekProgress = (seekProgress + 0.05f).coerceAtMost(1f) + } else { + focusManager.moveFocus(FocusDirection.Right) + } + }, + ) + .focusable(interactionSource = interactionSource), + ) { + val yOffset = size.height.div(2) + drawLine( + color = color.copy(alpha = 0.24f), + start = Offset(x = 0f, y = yOffset), + end = Offset(x = size.width, y = yOffset), + strokeWidth = size.height.div(2), + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(x = 0f, y = yOffset), + end = Offset( + x = size.width.times(if (isSelected) seekProgress else progress), + y = yOffset, + ), + strokeWidth = size.height.div(2), + cap = StrokeCap.Round, + ) + drawCircle( + color = Color.White, + radius = size.height.div(2), + center = Offset( + x = size.width.times(if (isSelected) seekProgress else progress), + y = yOffset, + ), + ) + } +} + +@Preview +@Composable +fun VideoPlayerSeekBarPreview() { + FindroidTheme { + VideoPlayerSeekBar( + progress = 0.4f, + onSeek = {}, + state = rememberVideoPlayerState(), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt new file mode 100644 index 00000000..400196a1 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerSeeker.kt @@ -0,0 +1,120 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings +import kotlin.time.Duration +import dev.jdtech.jellyfin.core.R as CoreR + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun VideoPlayerSeeker( + focusRequester: FocusRequester, + state: VideoPlayerState, + isPlaying: Boolean, + onPlayPauseToggle: (Boolean) -> Unit, + onSeek: (Float) -> Unit, + contentProgress: Duration, + contentDuration: Duration, +) { + val contentProgressString = + contentProgress.toComponents { h, m, s, _ -> + if (h > 0) { + "$h:${m.padStartWith0()}:${s.padStartWith0()}" + } else { + "${m.padStartWith0()}:${s.padStartWith0()}" + } + } + val contentDurationString = + contentDuration.toComponents { h, m, s, _ -> + if (h > 0) { + "$h:${m.padStartWith0()}:${s.padStartWith0()}" + } else { + "${m.padStartWith0()}:${s.padStartWith0()}" + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + onPlayPauseToggle(!isPlaying) + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + if (!isPlaying) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_play), + contentDescription = null, + ) + } else { + Icon( + painter = painterResource(id = CoreR.drawable.ic_pause), + contentDescription = null, + ) + } + } + Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium)) + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = contentProgressString, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + Text( + text = contentDurationString, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + } + Spacer(modifier = Modifier.height(MaterialTheme.spacings.small)) + VideoPlayerSeekBar( + progress = (contentProgress / contentDuration).toFloat(), + onSeek = onSeek, + state = state, + ) + } + } +} + +@Preview +@Composable +private fun VideoPlayerSeekerPreview() { + FindroidTheme { + VideoPlayerSeeker( + focusRequester = FocusRequester(), + state = rememberVideoPlayerState(), + isPlaying = false, + onPlayPauseToggle = {}, + onSeek = {}, + contentProgress = Duration.parse("7m 51s"), + contentDuration = Duration.parse("23m 40s"), + ) + } +} + +private fun Number.padStartWith0() = this.toString().padStart(2, '0') diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt new file mode 100644 index 00000000..332cb452 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/components/player/VideoPlayerState.kt @@ -0,0 +1,45 @@ +package dev.jdtech.jellyfin.ui.components.player + +import androidx.annotation.IntRange +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 kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.debounce + +class VideoPlayerState internal constructor( + @IntRange(from = 0) + private val hideSeconds: Int, +) { + private var _controlsVisible by mutableStateOf(true) + val controlsVisible get() = _controlsVisible + + fun showControls(seconds: Int = hideSeconds) { + _controlsVisible = true + channel.trySend(seconds) + } + + private val channel = Channel(CONFLATED) + + @OptIn(FlowPreview::class) + suspend fun observe() { + channel.consumeAsFlow() + .debounce { it.toLong() * 1000 } + .collect { _controlsVisible = false } + } +} + +@Composable +fun rememberVideoPlayerState(@IntRange(from = 0) hideSeconds: Int = 2) = + remember { + VideoPlayerState(hideSeconds = hideSeconds) + } + .also { + LaunchedEffect(it) { it.observe() } + } diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt new file mode 100644 index 00000000..6c5f52fe --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/BaseDialogStyle.kt @@ -0,0 +1,12 @@ +package dev.jdtech.jellyfin.ui.dialogs + +import androidx.compose.ui.window.DialogProperties +import com.ramcosta.composedestinations.spec.DestinationStyle + +object BaseDialogStyle : DestinationStyle.Dialog { + override val properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = true, + usePlatformDefaultWidth = false, + ) +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt new file mode 100644 index 00000000..fb2e583d --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dialogs/VideoPlayerTrackSelectorDialog.kt @@ -0,0 +1,120 @@ +package dev.jdtech.jellyfin.ui.dialogs + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Border +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ClickableSurfaceScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import dev.jdtech.jellyfin.models.Track +import dev.jdtech.jellyfin.ui.theme.FindroidTheme +import dev.jdtech.jellyfin.ui.theme.spacings + +@OptIn(ExperimentalTvMaterial3Api::class) +@Destination(style = BaseDialogStyle::class) +@Composable +fun VideoPlayerTrackSelectorDialog( + tracks: ArrayList, + resultNavigator: ResultBackNavigator, +) { + Surface { + Column( + modifier = Modifier.padding(MaterialTheme.spacings.medium), + ) { + Text( + text = "Select track", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacings.medium)) + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacings.medium - MaterialTheme.spacings.extraSmall), + contentPadding = PaddingValues(vertical = MaterialTheme.spacings.extraSmall), + ) { + items(tracks) { track -> + Surface( + onClick = { + resultNavigator.navigateBack(result = track.id) + }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + BorderStroke( + 4.dp, + Color.White, + ), + shape = RoundedCornerShape(10.dp), + ), + ), + scale = ClickableSurfaceScale.None, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(MaterialTheme.spacings.extraSmall), + ) { + RadioButton( + selected = track.selected, + onClick = null, + enabled = true, + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacings.medium)) + Text(text = listOf(track.label, track.language, track.codec).mapNotNull { it }.joinToString(" - "), style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun VideoPlayerTrackSelectorDialogPreview() { + FindroidTheme { + VideoPlayerTrackSelectorDialog( + tracks = arrayListOf( + Track( + id = 0, + label = null, + language = "English", + codec = "flac", + selected = true, + ), + Track( + id = 0, + label = null, + language = "Japanese", + codec = "flac", + selected = false, + ), + ), + resultNavigator = EmptyResultBackNavigator(), + ) + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt new file mode 100644 index 00000000..fb7c3f35 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Collections.kt @@ -0,0 +1,25 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.CollectionType +import dev.jdtech.jellyfin.models.FindroidCollection +import dev.jdtech.jellyfin.models.FindroidImages +import java.util.UUID + +private val dummyMoviesCollection = FindroidCollection( + id = UUID.randomUUID(), + name = "Movies", + type = CollectionType.Movies, + images = FindroidImages(), +) + +private val dummyShowsCollection = FindroidCollection( + id = UUID.randomUUID(), + name = "Shows", + type = CollectionType.TvShows, + images = FindroidImages(), +) + +val dummyCollections = listOf( + dummyMoviesCollection, + dummyShowsCollection, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt new file mode 100644 index 00000000..f02a8a31 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Episodes.kt @@ -0,0 +1,66 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.EpisodeItem +import dev.jdtech.jellyfin.models.FindroidEpisode +import dev.jdtech.jellyfin.models.FindroidImages +import dev.jdtech.jellyfin.models.FindroidMediaStream +import dev.jdtech.jellyfin.models.FindroidSource +import dev.jdtech.jellyfin.models.FindroidSourceType +import org.jellyfin.sdk.model.api.MediaStreamType +import java.time.LocalDateTime +import java.util.UUID + +val dummyEpisode = FindroidEpisode( + id = UUID.randomUUID(), + name = "Mother and Children", + originalTitle = null, + overview = "Stories are lies meant to entertain, and idols lie to fans eager to believe. This is Ai’s story. It is a lie, but it is also true.", + indexNumber = 1, + indexNumberEnd = null, + parentIndexNumber = 1, + sources = listOf( + FindroidSource( + id = "", + name = "", + type = FindroidSourceType.REMOTE, + path = "", + size = 0L, + mediaStreams = listOf( + FindroidMediaStream( + title = "", + displayTitle = "", + language = "en", + type = MediaStreamType.VIDEO, + codec = "hevc", + isExternal = false, + path = "", + channelLayout = null, + videoRangeType = null, + height = 1080, + width = 1920, + videoDoViTitle = null, + ), + ), + ), + ), + played = true, + favorite = true, + canPlay = true, + canDownload = true, + runtimeTicks = 20L, + playbackPositionTicks = 0L, + premiereDate = LocalDateTime.parse("2019-02-14T00:00:00"), + seriesName = "Oshi no Ko", + seriesId = UUID.randomUUID(), + seasonId = UUID.randomUUID(), + communityRating = 9.2f, + images = FindroidImages(), +) + +val dummyEpisodes = listOf( + dummyEpisode, +) + +val dummyEpisodeItems = listOf( + EpisodeItem.Episode(dummyEpisode), +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt new file mode 100644 index 00000000..9a335d4f --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/HomeItems.kt @@ -0,0 +1,26 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.CollectionType +import dev.jdtech.jellyfin.models.HomeItem +import dev.jdtech.jellyfin.models.HomeSection +import dev.jdtech.jellyfin.models.UiText +import dev.jdtech.jellyfin.models.View +import java.util.UUID + +val dummyHomeItems = listOf( + HomeItem.Section( + HomeSection( + id = UUID.randomUUID(), + name = UiText.DynamicString("Continue watching"), + items = dummyMovies + dummyEpisodes, + ), + ), + HomeItem.ViewItem( + View( + id = UUID.randomUUID(), + name = "Movies", + items = dummyMovies, + type = CollectionType.Movies, + ), + ), +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt new file mode 100644 index 00000000..8ba5506a --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Movies.kt @@ -0,0 +1,62 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.FindroidImages +import dev.jdtech.jellyfin.models.FindroidMediaStream +import dev.jdtech.jellyfin.models.FindroidMovie +import dev.jdtech.jellyfin.models.FindroidSource +import dev.jdtech.jellyfin.models.FindroidSourceType +import org.jellyfin.sdk.model.api.MediaStreamType +import java.time.LocalDateTime +import java.util.UUID + +val dummyMovie = FindroidMovie( + id = UUID.randomUUID(), + name = "Alita: Battle Angel", + originalTitle = null, + overview = "When Alita awakens with no memory of who she is in a future world she does not recognize, she is taken in by Ido, a compassionate doctor who realizes that somewhere in this abandoned cyborg shell is the heart and soul of a young woman with an extraordinary past.", + sources = listOf( + FindroidSource( + id = "", + name = "", + type = FindroidSourceType.REMOTE, + path = "", + size = 0L, + mediaStreams = listOf( + FindroidMediaStream( + title = "", + displayTitle = "", + language = "en", + type = MediaStreamType.VIDEO, + codec = "hevc", + isExternal = false, + path = "", + channelLayout = null, + videoRangeType = null, + height = 1080, + width = 1920, + videoDoViTitle = null, + ), + ), + ), + ), + played = false, + favorite = true, + canPlay = true, + canDownload = true, + runtimeTicks = 20L, + playbackPositionTicks = 15L, + premiereDate = LocalDateTime.parse("2019-02-14T00:00:00"), + people = emptyList(), + genres = listOf("Action", "Sience Fiction", "Adventure"), + communityRating = 7.2f, + officialRating = "PG-13", + status = "Ended", + productionYear = 2019, + endDate = null, + trailer = "https://www.youtube.com/watch?v=puKWa8hrvA8", + images = FindroidImages(), +) + +val dummyMovies = listOf( + dummyMovie, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt new file mode 100644 index 00000000..2c8995b2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Servers.kt @@ -0,0 +1,22 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.DiscoveredServer +import dev.jdtech.jellyfin.models.Server +import java.util.UUID + +val dummyDiscoveredServer = DiscoveredServer( + id = "", + name = "Demo server", + address = "https://demo.jellyfin.org/stable", +) + +val dummyDiscoveredServers = listOf(dummyDiscoveredServer) + +val dummyServer = Server( + id = "", + name = "Demo server", + currentServerAddressId = UUID.randomUUID(), + currentUserId = UUID.randomUUID(), +) + +val dummyServers = listOf(dummyServer) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt new file mode 100644 index 00000000..00cfb073 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Show.kt @@ -0,0 +1,30 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.FindroidImages +import dev.jdtech.jellyfin.models.FindroidShow +import java.time.LocalDateTime +import java.util.UUID + +val dummyShow = FindroidShow( + id = UUID.randomUUID(), + name = "Attack on Titan", + originalTitle = null, + overview = "After his hometown is destroyed and his mother is killed, young Eren Yeager vows to cleanse the earth of the giant humanoid Titans that have brought humanity to the brink of extinction.", + sources = emptyList(), + played = false, + favorite = false, + canPlay = true, + canDownload = false, + runtimeTicks = 0L, + communityRating = 8.8f, + endDate = LocalDateTime.parse("2023-11-04T00:00:00"), + genres = listOf("Action", "Sience Fiction", "Adventure"), + images = FindroidImages(), + officialRating = "TV-MA", + people = emptyList(), + productionYear = 2013, + seasons = emptyList(), + status = "Ended", + trailer = null, + unplayedItemCount = 20, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt new file mode 100644 index 00000000..868d0d57 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/dummy/Users.kt @@ -0,0 +1,12 @@ +package dev.jdtech.jellyfin.ui.dummy + +import dev.jdtech.jellyfin.models.User +import java.util.UUID + +val dummyUser = User( + id = UUID.randomUUID(), + name = "Username", + serverId = "", +) + +val dummyUsers = listOf(dummyUser) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt new file mode 100644 index 00000000..e662f891 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Color.kt @@ -0,0 +1,34 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.darkColorScheme as darkColorSchemeTv + +val PrimaryDark = Color(0xffa1c9ff) +val OnPrimaryDark = Color(0xff00315e) +val PrimaryContainerDark = Color(0xff004884) +val OnPrimaryContainerDark = Color(0xffd3e4ff) +val Neutral900 = Color(0xff121A21) +val Neutral1000 = Color(0xff000000) + +val Yellow = Color(0xFFF2C94C) + +val ColorScheme = darkColorScheme( + primary = PrimaryDark, + onPrimary = OnPrimaryDark, + primaryContainer = PrimaryContainerDark, + onPrimaryContainer = OnPrimaryContainerDark, + surface = Neutral900, + background = Neutral1000, +) + +@OptIn(ExperimentalTvMaterial3Api::class) +val ColorSchemeTv = darkColorSchemeTv( + primary = ColorScheme.primary, + onPrimary = ColorScheme.onPrimary, + primaryContainer = ColorScheme.primaryContainer, + onPrimaryContainer = ColorScheme.onPrimaryContainer, + surface = ColorScheme.surface, + background = ColorScheme.background, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt new file mode 100644 index 00000000..5470e4c3 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Shape.kt @@ -0,0 +1,18 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Shapes as ShapesTv + +val shapes = Shapes( + extraSmall = RoundedCornerShape(10.dp), + small = RoundedCornerShape(10.dp), +) + +@OptIn(ExperimentalTvMaterial3Api::class) +val shapesTv = ShapesTv( + extraSmall = shapes.extraSmall, + small = shapes.small, +) diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt new file mode 100644 index 00000000..0d69d897 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Spacing.kt @@ -0,0 +1,25 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme + +@Immutable +data class Spacings( + val default: Dp = 24.dp, + val extraSmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 32.dp, + val extraLarge: Dp = 64.dp, +) + +@OptIn(ExperimentalTvMaterial3Api::class) +val MaterialTheme.spacings + get() = Spacings() + +@OptIn(ExperimentalTvMaterial3Api::class) +val LocalSpacings = compositionLocalOf { MaterialTheme.spacings } diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt new file mode 100644 index 00000000..9d107a4f --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Theme.kt @@ -0,0 +1,30 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme as MaterialThemeTv + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun FindroidTheme( + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = ColorScheme, + typography = Typography, + shapes = shapes, + ) { + CompositionLocalProvider( + LocalSpacings provides Spacings(), + ) { + MaterialThemeTv( + colorScheme = ColorSchemeTv, + typography = TypographyTv, + shapes = shapesTv, + content = content, + ) + } + } +} diff --git a/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt new file mode 100644 index 00000000..ff6732a2 --- /dev/null +++ b/app/tv/src/main/java/dev/jdtech/jellyfin/ui/theme/Type.kt @@ -0,0 +1,45 @@ +package dev.jdtech.jellyfin.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Typography as TypographyTv + +val Typography = Typography( + displayMedium = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 48.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 24.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + ), +) + +@OptIn(ExperimentalTvMaterial3Api::class) +val TypographyTv = TypographyTv( + displayMedium = Typography.displayMedium, + headlineMedium = Typography.headlineMedium, + titleMedium = Typography.titleMedium, + titleSmall = Typography.titleSmall, + bodyMedium = Typography.bodyMedium, + labelMedium = Typography.labelMedium, +) diff --git a/app/tv/src/main/res/drawable/ic_banner.xml b/app/tv/src/main/res/drawable/ic_banner.xml new file mode 100644 index 00000000..b2adc4c4 --- /dev/null +++ b/app/tv/src/main/res/drawable/ic_banner.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/tv/src/main/res/values/themes.xml b/app/tv/src/main/res/values/themes.xml new file mode 100644 index 00000000..df0af6c3 --- /dev/null +++ b/app/tv/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +