Compare commits
365 Commits
b41a9c24a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 660b50d7cd | |||
| 7472b35d66 | |||
| d8de27c972 | |||
| e33a92271a | |||
| 869bb27497 | |||
| a4bb57822a | |||
| 2d649cdff9 | |||
| 33c83bd30f | |||
| 37bf64f5f8 | |||
| e6da94970a | |||
| 28b647e209 | |||
| b6ebe3d427 | |||
| 76d75221cd | |||
| 6b9724c640 | |||
| c40df41603 | |||
| 0feb16371e | |||
| ced9ca7b9d | |||
| 79066b2ce5 | |||
| dfab094782 | |||
| 26d5e7fdcb | |||
| 5720d4e45b | |||
| f4d2fdd3f7 | |||
| a2121afc2d | |||
| 4879eae9bb | |||
| b28637be4f | |||
| b2db0c2553 | |||
| 7f68b9d32b | |||
| cd3fa8ace1 | |||
| bb61db17bd | |||
| 3dc757f0f7 | |||
| f73ca4e242 | |||
| f25f0e0827 | |||
| 8d194f2baf | |||
| c836c809fc | |||
| b6fc10df57 | |||
| 18e68c6051 | |||
| e8224849cc | |||
| c2cc4e9e65 | |||
| 13bc9410b4 | |||
| 422642ad19 | |||
| 59315b701d | |||
| 3c46650a63 | |||
| f0a7828613 | |||
| 8c758e8262 | |||
| 9872f401f8 | |||
| 86f44ac9ce | |||
| d80b283ded | |||
| 1ac649e8ae | |||
| b23db4bab3 | |||
| 67a141e7d7 | |||
| 9f432edd27 | |||
| 80b1bd8547 | |||
| 00c5689195 | |||
| bd2c1777a5 | |||
| a5d04998ac | |||
| da89a8a661 | |||
| 53a7d4ad23 | |||
| 8ac0fd0bd1 | |||
| 1910a0c144 | |||
| 217ba5b6d9 | |||
| fee0e703eb | |||
| 0fc4644fdb | |||
| 6e48b35741 | |||
| 8d5948438e | |||
| 8a2702a7cb | |||
| 5a98f3d2e7 | |||
| 80f25c6dea | |||
| b19d41abc6 | |||
| 831abfebb4 | |||
| eb540a0fbb | |||
| 29c48a50b6 | |||
| 05f58295ad | |||
| 31f9fe5d4c | |||
| 1ec9ff9c95 | |||
| 5491c381a3 | |||
| 34340c2bd4 | |||
| f6c465656e | |||
| 54c895ff0f | |||
| a4267d85ce | |||
| a07f9dd6e4 | |||
| 50bbf5b81f | |||
| 1bd51d8a9f | |||
| 615e95db5c | |||
| ccd5a7f50e | |||
| d0149bf647 | |||
| 9bea9a81ff | |||
| 8d74d0c5df | |||
| e78a3700a6 | |||
| def92ad111 | |||
| c92a8831a5 | |||
| fe66926335 | |||
| 4727cdcab1 | |||
| 2853609cbb | |||
| 327e68af19 | |||
| 8dcb6aeea9 | |||
| df2a468e89 | |||
| 4a6977eefb | |||
| 3f9fd6e36e | |||
| 10e7b450f3 | |||
| c81b228bb2 | |||
| f94bdc5bce | |||
| 4ade70a5de | |||
| ce907cee0c | |||
| dd66302da0 | |||
| 753b461453 | |||
| 0bf968a42b | |||
| 005b3249ff | |||
| 84157868d0 | |||
| 25b65b9048 | |||
| ed418a8e44 | |||
| fecc962950 | |||
| 1ae2541773 | |||
| 88e8b3a41f | |||
| 85c53f8704 | |||
| 448550b5ab | |||
| 9f8292b04d | |||
| fa87005d01 | |||
| 1ad7bc4e23 | |||
| e72c421294 | |||
| 9aec271aa4 | |||
| 717bf3c8e1 | |||
| 0011f3842a | |||
| 670e937566 | |||
| 38f1f94956 | |||
| 72cb574753 | |||
| 80ab591662 | |||
| 0eea3165de | |||
| b1b154800c | |||
| 5c1ddffe7a | |||
| 97dcea57ec | |||
| 09a1bb2288 | |||
| 8064367e7a | |||
| dedd4f1da5 | |||
| e58afd26d5 | |||
| 15c1e806da | |||
| 194052517b | |||
| b0ed32ef7c | |||
| 3cd706d449 | |||
| 1dedc7ff0a | |||
| 285fda9caf | |||
| 3e94e8193a | |||
|
|
0a56f46da7 | ||
| b226eaeba2 | |||
| ef56db5d16 | |||
|
|
d81c382ba5 | ||
| 6e90a9e225 | |||
| 8d73974add | |||
|
|
2e4eb2fb30 | ||
| 4c15474ad9 | |||
| bbec9c1e06 | |||
|
|
e82e370e48 | ||
| 41ae5f939d | |||
|
|
d14eec2346 | ||
|
|
70a587b743 | ||
| 56da98a90b | |||
|
|
5dabc8bbf0 | ||
|
|
8da759257c | ||
|
|
645e28ab30 | ||
| 1274ab25f9 | |||
| 56a2b42434 | |||
| 069f29c1c8 | |||
| f7b266ec8c | |||
| fcf7c435be | |||
| aa884994c2 | |||
| 7ae0c2eb27 | |||
| b7186be46a | |||
| deacb89adb | |||
| e09b63f47e | |||
| 1fc2c8cf05 | |||
| c70657e627 | |||
| 063f2fd5ca | |||
| 4b87f93dee | |||
| f95fbf72b3 | |||
| 6cff6be238 | |||
| a0a51ef436 | |||
| f60aba5a72 | |||
| 83730188eb | |||
| a92c10a07c | |||
| 657049edc9 | |||
| 70f086b5a1 | |||
| cc643ae34f | |||
| 9fb3672c19 | |||
| 6a5f2fd593 | |||
| 8950a37341 | |||
| 7ec9a0f41f | |||
| d3b66d4fc5 | |||
| e69a3a6d4a | |||
| dbcabd0848 | |||
| beae723372 | |||
| 147fabd895 | |||
| 56aa16755d | |||
| f5d476b28e | |||
| 2f6d7e77c3 | |||
| fbb19e6970 | |||
| 58d418ff65 | |||
| 3ee4b558f5 | |||
| 406d0f4ed8 | |||
| 4e9d8c0b3b | |||
| 4e322e7723 | |||
| 2c0d689767 | |||
| 26a134869e | |||
| 80f5454afb | |||
| 60d2a1eb2a | |||
| 0554f328c6 | |||
| af2e8b0dc9 | |||
| e05041fd14 | |||
| 9313cded53 | |||
| 4788eec102 | |||
| 5444d3c8b1 | |||
| 9d7c166bc8 | |||
| dddf6e4cfa | |||
| 7a04c59023 | |||
| 3384a6795b | |||
| dc5c324962 | |||
| 56fe574940 | |||
| 607bfca0c0 | |||
| f75f18d2af | |||
| 322eb69ab5 | |||
| 41f7129b08 | |||
| ca3e5dfdf5 | |||
| bc4b26291f | |||
| ed80948676 | |||
| 9a83eb635f | |||
| 31c1b0dd92 | |||
| f2c46af410 | |||
| b1ae478f6d | |||
| 132c6feee0 | |||
| 9953c94e86 | |||
| db15bf7e2c | |||
| 63a8a108e2 | |||
| 5d12b97922 | |||
| fe0b76a0ad | |||
| a5bb1f84dc | |||
| f925ca57ea | |||
| a41836b925 | |||
| 5bec125f56 | |||
| 3a748397f8 | |||
| ae4c2b34f4 | |||
| f957098369 | |||
| 8bf3f08df0 | |||
| efd7593646 | |||
| 0edac36ca8 | |||
| 53bc6246e4 | |||
| 2753a45645 | |||
| fbcba6fbfc | |||
| dbac1b56c5 | |||
| 1f0c192460 | |||
| a0b573cbfe | |||
| 1a2687f1a2 | |||
| 803edcb80c | |||
| e55bd320d1 | |||
| fe1d449fe0 | |||
| f626c9533e | |||
| 6433225014 | |||
| 36e89f35d9 | |||
| 217e8df417 | |||
| 672d8e1d64 | |||
| c6fe84654f | |||
| 0f0c86dcf9 | |||
| 557679861a | |||
| 64b1032b74 | |||
| 44219d00d8 | |||
| cff0b60b31 | |||
| 9610124af0 | |||
| 185a0c8a85 | |||
| dfaf1079ac | |||
| 65693a9220 | |||
| 58087bb4a5 | |||
| 71a9a8b98e | |||
| f304b1f1f5 | |||
| e738e9637f | |||
| bfc007f24f | |||
| d815812e79 | |||
| b0baad7f8c | |||
| 3b95185f98 | |||
| 72fea00444 | |||
| 8f5d0c5bc1 | |||
| 6341510cc2 | |||
| 2ff6fe997e | |||
| 7401f986c6 | |||
| 93070ba23f | |||
| 56c4991467 | |||
| 7c38d22717 | |||
| 9f480659ec | |||
| 3c16817442 | |||
| c4eed9ece3 | |||
| f444a5e09f | |||
| 522b93ff06 | |||
| 1fc291d2d8 | |||
| 84a95b0af6 | |||
| 8a7577d5bb | |||
| 22c414b5e2 | |||
| 3d01f0faf3 | |||
| 3e3d99f1f2 | |||
| 790474d992 | |||
| 8a1d5ccfab | |||
| 72671d9ac3 | |||
| 5595a15820 | |||
| bd5e43114b | |||
| 8cc85d1525 | |||
| 4c986432f6 | |||
| afdd9b860f | |||
| 51ffd14804 | |||
| f086956578 | |||
| 01de2ca19c | |||
| 522c8273d5 | |||
| 9906d473c5 | |||
| 1b8300ee1d | |||
| aa58cf3aff | |||
| 81a3504606 | |||
| b15453ab01 | |||
| f9eaf0cf84 | |||
| 78a5f716c0 | |||
| f7f9fc1058 | |||
| 2433ececac | |||
| 354314710b | |||
| ab7700d202 | |||
| 7232352014 | |||
| 57e2983527 | |||
| a1239c907a | |||
| a9bd23e26c | |||
| 8423e63937 | |||
| 3f7c1966af | |||
| 59a471c535 | |||
| f0a4aadcf0 | |||
| 65f2bd811b | |||
| 899e2f08b0 | |||
| 188efde982 | |||
| 3ae1425eba | |||
| 7f83bb181b | |||
| a2931b78f9 | |||
| d0e5e978be | |||
| a39581682f | |||
| a9b0f771f8 | |||
| 18e352eb1e | |||
| 9545e4d619 | |||
| 920b0e0e4a | |||
| 2c5585417a | |||
| a0086c6f62 | |||
| 52a51b1b28 | |||
| 76eebc1139 | |||
| 632dda52d6 | |||
| 5a377fc428 | |||
| 858f599dbd | |||
| 18844fcd38 | |||
| 9591b72273 | |||
| 29747a1031 | |||
| ad930e899f | |||
| b67c8076f1 | |||
| 1e6c0e838e | |||
| 1f6e3a8b43 | |||
| 3509a09064 | |||
| 9a130e9738 | |||
| a0af187e8f | |||
| 16839942b1 | |||
| 360cd51e23 | |||
| 7c30e12af7 | |||
| 53f7cb58f3 | |||
| 313a971684 | |||
| 512f7bbb5b | |||
| 58980f4ac7 | |||
| d007c80af7 | |||
| 54e79d8bee | |||
| 4ef78b8ec6 | |||
| d253a6c8d0 |
2
.gitignore
vendored
@@ -18,3 +18,5 @@
|
||||
local.properties
|
||||
.codex/
|
||||
openspec
|
||||
.opencode
|
||||
*.patch
|
||||
|
||||
77
AGENTS.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Purefin AGENTS.md
|
||||
|
||||
Follow these instructions before making changes:
|
||||
|
||||
* Understand the relevant files before editing.
|
||||
* Always follow official Android recommendations and best practices.
|
||||
* Always include the pages you have read in your reply, even in planning mode.
|
||||
* Prefer small, targeted changes over broad refactors.
|
||||
* Preserve existing architecture and naming unless the task requires a change.
|
||||
* Do not introduce new dependencies without a clear reason.
|
||||
* Ask for clarification or leave a note when requirements are ambiguous.
|
||||
|
||||
---
|
||||
|
||||
## Ui principles
|
||||
* Ui design should align with the material ui 3 design system
|
||||
* For new components check https://m3.material.io/components if there is an available solution for the problem.
|
||||
* Using Box components are a red flag and should be only used when there is no other option.
|
||||
* When styling components check the material ui 3 site first and use it for the decision https://m3.material.io/styles
|
||||
|
||||
---
|
||||
|
||||
## Repository Expectations
|
||||
|
||||
* Always use the currently used syntax in the codebase. Include examples for any naming you introduce.
|
||||
* Avoid touching unrelated files.
|
||||
* Do not fix unrelated existing problems in the codebase.
|
||||
* Prefer consistency with the existing codebase over idealized patterns.
|
||||
* When modifying a file, match its local style and conventions.
|
||||
* Favor readable, maintainable code over clever code.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
* Running a build is sufficient to verify your implementation.
|
||||
* Do not write tests unless explicitly asked to.
|
||||
|
||||
---
|
||||
|
||||
## Git and Commit Rules
|
||||
|
||||
When asked to prepare a commit:
|
||||
|
||||
* Use **Conventional Commits** format.
|
||||
* Format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
* The description should not be too long, but must include any major change.
|
||||
* Use imperative mood.
|
||||
* Keep the subject concise.
|
||||
|
||||
**Common types:**
|
||||
|
||||
* `feat` – new feature
|
||||
* `fix` – bug fix
|
||||
* `refactor` – code change without feature or fix
|
||||
* `test` – adding or updating tests
|
||||
* `docs` – documentation changes
|
||||
* `chore` – maintenance tasks
|
||||
* `build` – build system changes
|
||||
* `ci` – CI/CD changes
|
||||
|
||||
---
|
||||
|
||||
## Preferred Workflow
|
||||
|
||||
1. Read the task carefully.
|
||||
2. Read relevant official Google/Android documentation.
|
||||
3. Inspect the relevant files and nearby code.
|
||||
4. Choose the simplest solution aligned with Google’s standards.
|
||||
5. Check if the code can be simplified:
|
||||
* If yes, simplify it.
|
||||
* Do not reduce readability.
|
||||
6. Summarize the result, including anything not verified.
|
||||
39
README.md
@@ -53,9 +53,44 @@ cd purefin
|
||||
- For Android devices: Select your device/emulator and click Run
|
||||
- For Android TV: Select an Android TV emulator or device
|
||||
|
||||
### Download APK
|
||||
### APK Repository
|
||||
|
||||
_Release builds coming soon_
|
||||
APKs are distributed via the [APK Version Manager](https://apks.t.bbara.hu/docs) at `https://apks.t.bbara.hu`.
|
||||
|
||||
#### App names
|
||||
|
||||
| Variant | App name | Update URL |
|
||||
|---------|----------|--------------------------------------------------------------|
|
||||
| Release (phone) | `purefin-app` | `https://apks.t.bbara.hu/apps/purefin-app/update.json` |
|
||||
| Debug (phone) | `purefin-app-debug` | `https://apks.t.bbara.hu/apps/purefin-app-debug/update.json` |
|
||||
| Release (TV) | `purefin-tv` | `https://apks.t.bbara.hu/apps/purefin-tv/update.json` |
|
||||
|
||||
#### Building
|
||||
|
||||
```bash
|
||||
# Phone
|
||||
./gradlew :app:assembleDebug # debug APK
|
||||
./gradlew :app:assembleRelease # release APK (unsigned)
|
||||
|
||||
# Android TV
|
||||
./gradlew :app-tv:assembleDebug # debug APK
|
||||
./gradlew :app-tv:assembleRelease # release APK (unsigned)
|
||||
```
|
||||
|
||||
APK outputs go to `app/build/outputs/apk/<variant>/` and `app-tv/build/outputs/apk/<variant>/`.
|
||||
|
||||
#### Uploading
|
||||
|
||||
Upload via the API at `POST /api/apps/{app_name}/upload`:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://apks.t.bbara.hu/api/apps/purefin-app-debug/upload" \
|
||||
-F "apk_file=@app/build/outputs/apk/debug/app-debug.apk" \
|
||||
-F "version_code=$(grep purefinDebugVersionCode gradle.properties | cut -d= -f2)" \
|
||||
-F "version_name=0.1-debug"
|
||||
```
|
||||
|
||||
The `version_code` is defined in `gradle.properties` (`purefinReleaseVersionCode` for phone release, `purefinDebugVersionCode` for phone debug, `purefinTvVersionCode` for TV). The `version_name` is `0.1` for release or `0.1-debug` for debug. Both fields are required — the server cannot auto-extract them from the APK.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -7,17 +7,19 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.google.gms.google.services)
|
||||
alias(libs.plugins.google.firebase.crashlytics)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.tv"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "hu.bbara.purefin.tv"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 2000001
|
||||
versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
|
||||
versionName = "0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -51,12 +53,15 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:player"))
|
||||
implementation(project(":feature:shared"))
|
||||
implementation(project(":data"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":core-model"))
|
||||
implementation(project(":core-ui"))
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -83,8 +88,7 @@ dependencies {
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
29
app-tv/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "407366050869",
|
||||
"project_id": "purefin-12eec",
|
||||
"storage_bucket": "purefin-12eec.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:407366050869:android:8fa3540adfc514add73d7c",
|
||||
"android_client_info": {
|
||||
"package_name": "hu.bbara.purefin.tv"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD8a9BnlFSmcw4v0gukC1qcyESznUUWsXI"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
package hu.bbara.purefin.ui.common.media
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
package hu.bbara.purefin.ui.screen.episode
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
@@ -13,9 +13,10 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -31,7 +32,7 @@ class EpisodeScreenContentTest {
|
||||
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
TvEpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
onPlay = {}
|
||||
@@ -55,7 +56,7 @@ class EpisodeScreenContentTest {
|
||||
fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
TvEpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = null),
|
||||
seriesTitle = "Severance",
|
||||
onPlay = {}
|
||||
@@ -77,7 +78,7 @@ class EpisodeScreenContentTest {
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
TvEpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
onPlay = { playCount++ }
|
||||
@@ -104,7 +105,9 @@ class EpisodeScreenContentTest {
|
||||
return Episode(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 2,
|
||||
index = 4,
|
||||
title = "The You You Are",
|
||||
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -16,16 +16,15 @@ import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -68,7 +67,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = emptyList(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -80,7 +78,6 @@ class TvHomeContentTest {
|
||||
|
||||
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
|
||||
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,7 +89,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatchingRow(),
|
||||
nextUp = emptyList(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -126,7 +122,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = emptyList(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -156,7 +151,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUp(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -196,7 +190,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = emptyList(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -228,7 +221,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUp(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -266,7 +258,6 @@ class TvHomeContentTest {
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = sampleNextUpRow(),
|
||||
serverUrl = "",
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
@@ -295,11 +286,49 @@ class TvHomeContentTest {
|
||||
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
|
||||
}
|
||||
|
||||
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
|
||||
@Test
|
||||
fun tvHomeScreen_usesReducedMediaCardWidths() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||
) {
|
||||
TvHomeScreen(
|
||||
libraries = sampleLibraries(),
|
||||
libraryContent = sampleLibraryContent(),
|
||||
continueWatching = sampleContinueWatching(),
|
||||
nextUp = emptyList(),
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val expectedLandscapeWidth = with(composeRule.density) { 223.2.dp.toPx() }
|
||||
assertEquals(expectedLandscapeWidth, nodeWidth(TvHomeInitialFocusTag), 2f)
|
||||
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val expectedPosterWidth = with(composeRule.density) { 122.4.dp.toPx() }
|
||||
composeRule.onNodeWithTag(tvHomeLibraryFirstItemTag(sampleLibraryId())).assertIsDisplayed()
|
||||
assertEquals(
|
||||
expectedPosterWidth,
|
||||
nodeWidth(tvHomeLibraryFirstItemTag(sampleLibraryId())),
|
||||
2f
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleContinueWatching(): List<MediaUiModel> {
|
||||
return listOf(
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = sampleMovie(
|
||||
MovieUiModel(
|
||||
sampleMovie(
|
||||
id = "11111111-1111-1111-1111-111111111111",
|
||||
title = "Blade Runner 2049",
|
||||
progress = 42.0
|
||||
@@ -308,19 +337,17 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
|
||||
private fun sampleContinueWatchingRow(): List<MediaUiModel> {
|
||||
return listOf(
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = sampleMovie(
|
||||
MovieUiModel(
|
||||
sampleMovie(
|
||||
id = "11111111-1111-1111-1111-111111111111",
|
||||
title = "Blade Runner 2049",
|
||||
progress = 42.0
|
||||
)
|
||||
),
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = sampleMovie(
|
||||
MovieUiModel(
|
||||
sampleMovie(
|
||||
id = "55555555-5555-5555-5555-555555555555",
|
||||
title = "Mad Max: Fury Road",
|
||||
progress = 8.0
|
||||
@@ -329,10 +356,10 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleNextUp(): List<NextUpItem> {
|
||||
private fun sampleNextUp(): List<MediaUiModel> {
|
||||
return listOf(
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
EpisodeUiModel(
|
||||
sampleEpisode(
|
||||
id = "66666666-6666-6666-6666-666666666666",
|
||||
title = "The Train Job"
|
||||
)
|
||||
@@ -340,16 +367,16 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleNextUpRow(): List<NextUpItem> {
|
||||
private fun sampleNextUpRow(): List<MediaUiModel> {
|
||||
return listOf(
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
EpisodeUiModel(
|
||||
sampleEpisode(
|
||||
id = "66666666-6666-6666-6666-666666666666",
|
||||
title = "The Train Job"
|
||||
)
|
||||
),
|
||||
NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
EpisodeUiModel(
|
||||
sampleEpisode(
|
||||
id = "77777777-7777-7777-7777-777777777777",
|
||||
title = "Safe",
|
||||
releaseDate = "2024-02-09"
|
||||
@@ -358,26 +385,26 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleLibraries(): List<LibraryItem> {
|
||||
private fun sampleLibraries(): List<LibraryUiModel> {
|
||||
return listOf(
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = sampleLibraryId(),
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
||||
private fun sampleLibraryContent(): Map<UUID, List<MediaUiModel>> {
|
||||
val libraryId = sampleLibraryId()
|
||||
|
||||
return mapOf(
|
||||
libraryId to listOf(
|
||||
PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = sampleMovie(
|
||||
MovieUiModel(
|
||||
sampleMovie(
|
||||
id = "44444444-4444-4444-4444-444444444444",
|
||||
title = "Arrival",
|
||||
progress = null,
|
||||
@@ -399,7 +426,9 @@ class TvHomeContentTest {
|
||||
return Episode(
|
||||
id = UUID.fromString(id),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Firefly",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = title,
|
||||
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
||||
@@ -462,4 +491,11 @@ class TvHomeContentTest {
|
||||
.boundsInRoot
|
||||
.top
|
||||
}
|
||||
|
||||
private fun nodeWidth(tag: String): Float {
|
||||
return composeRule.onNodeWithTag(tag, useUnmergedTree = true)
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
.width
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TvNavigationDrawerTest {
|
||||
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
|
||||
|
||||
@Test
|
||||
fun tvNavigationDrawer_updatesSelectedDestination() {
|
||||
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME)
|
||||
val libraryRoute = Route.LibraryRoute(
|
||||
library = LibraryDto(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Movies"
|
||||
)
|
||||
)
|
||||
var selectedDestination by mutableStateOf<Route>(Route.Home)
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvNavigationDrawer(
|
||||
destinations = listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
destination = Route.Home,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home
|
||||
),
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.LIBRARIES,
|
||||
label = "Libraries",
|
||||
destination = libraryRoute,
|
||||
label = "Movies",
|
||||
icon = Icons.Outlined.Collections
|
||||
)
|
||||
),
|
||||
@@ -1,12 +1,12 @@
|
||||
package hu.bbara.purefin.tv.library.ui
|
||||
package hu.bbara.purefin.ui.screen.libraries
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -20,22 +20,24 @@ class TvLibrariesOverviewScreenTest {
|
||||
@Test
|
||||
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
|
||||
val libraries = listOf(
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
),
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Shows",
|
||||
type = CollectionType.TVSHOWS,
|
||||
type = LibraryKind.SERIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
var openedLibrary: LibraryItem? = null
|
||||
var openedLibrary: LibraryUiModel? = null
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
package hu.bbara.purefin.ui.screen.movie
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
@@ -13,9 +13,10 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -31,7 +32,7 @@ class MovieScreenContentTest {
|
||||
fun movieScreenContent_focusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
MovieScreenContent(
|
||||
TvMovieScreenContent(
|
||||
movie = sampleMovie(progress = 42.0),
|
||||
onPlay = {}
|
||||
)
|
||||
@@ -54,7 +55,7 @@ class MovieScreenContentTest {
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
MovieScreenContent(
|
||||
TvMovieScreenContent(
|
||||
movie = sampleMovie(progress = 42.0),
|
||||
onPlay = { playCount++ }
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
package hu.bbara.purefin.ui.screen.series
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
@@ -11,11 +11,13 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesNextUpEpisodeCardTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -28,10 +30,10 @@ class SeriesScreenContentTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
TvSeriesScreenContent(
|
||||
series = sampleSeriesWithEpisodes(),
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
@@ -42,8 +44,7 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||
@@ -54,7 +55,7 @@ class SeriesScreenContentTest {
|
||||
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
TvSeriesScreenContent(
|
||||
series = sampleSeriesWithoutEpisodes(),
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
@@ -64,7 +65,6 @@ class SeriesScreenContentTest {
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
@@ -76,7 +76,7 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
TvSeriesScreenContent(
|
||||
series = sampleSeriesWithEpisodes(),
|
||||
onPlayEpisode = { playCount++ }
|
||||
)
|
||||
@@ -85,7 +85,7 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
@@ -120,7 +120,9 @@ class SeriesScreenContentTest {
|
||||
Episode(
|
||||
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = "Good News About Hell",
|
||||
synopsis = "Mark is promoted after an unexpected tragedy.",
|
||||
@@ -136,7 +138,9 @@ class SeriesScreenContentTest {
|
||||
Episode(
|
||||
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 2,
|
||||
title = "Half Loop",
|
||||
synopsis = "Mark takes the team out for a sanctioned dinner.",
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -14,7 +16,7 @@
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".TvApplication"
|
||||
android:name="hu.bbara.purefin.tv.TvApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/banner"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -23,9 +25,13 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/purefin_logo_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
|
||||
<activity
|
||||
android:name=".TvActivity"
|
||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<intent-filter>
|
||||
@@ -34,6 +40,9 @@
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
||||
|
||||
@Composable
|
||||
fun EpisodeScreen(
|
||||
episode: EpisodeDto,
|
||||
viewModel: EpisodeScreenViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
|
||||
LaunchedEffect(episode) {
|
||||
viewModel.selectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
)
|
||||
}
|
||||
|
||||
val episode = viewModel.episode.collectAsState()
|
||||
val seriesTitle = viewModel.seriesTitle.collectAsState()
|
||||
val selectedEpisode = episode.value
|
||||
|
||||
if (selectedEpisode == null) {
|
||||
PurefinWaitingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
EpisodeScreenContent(
|
||||
episode = selectedEpisode,
|
||||
seriesTitle = seriesTitle.value,
|
||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||
{
|
||||
navigationManager.navigate(
|
||||
Route.PlayerRoute(mediaId = selectedEpisode.id.toString())
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeScreenContent(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(episode.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = episode.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
EpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = episode.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
|
||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun MovieHeroSection(
|
||||
movie: Movie,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = movie.year)
|
||||
MediaMetaChip(text = movie.rating)
|
||||
MediaMetaChip(text = movie.runtime)
|
||||
MediaMetaChip(
|
||||
text = movie.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.35f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = movie.playButtonText(),
|
||||
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(MoviePlayButtonTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Movie.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||
|
||||
@Composable
|
||||
fun MovieScreen(
|
||||
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(movie.id) {
|
||||
viewModel.selectMovie(movie.id)
|
||||
}
|
||||
|
||||
val movieItem = viewModel.movie.collectAsState()
|
||||
|
||||
if (movieItem.value != null) {
|
||||
MovieScreenContent(
|
||||
movie = movieItem.value!!,
|
||||
onPlay = viewModel::onPlay,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
PurefinWaitingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MovieScreenContent(
|
||||
movie: Movie,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(movie.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = movie.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
MovieHeroSection(
|
||||
movie = movie,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = movie.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = movie.audioTrack,
|
||||
subtitles = movie.subtitles,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = movie.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SeriesMetaChips(series: Series) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = series.year)
|
||||
MediaMetaChip(text = "${series.seasonCount} Seasons")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeasonTabs(
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
SeasonTab(
|
||||
name = season.name,
|
||||
isSelected = season == selectedSeason,
|
||||
onSelect = { onSelect(season) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonTab(
|
||||
name: String,
|
||||
isSelected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val color = if (isSelected || isFocused) scheme.primary else mutedStrong
|
||||
val underlineColor = if (isSelected || isFocused) scheme.primary else Color.Transparent
|
||||
val underlineHeight = if (isFocused) 3.dp else 2.dp
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onSelect() }
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(underlineHeight)
|
||||
.width(52.dp)
|
||||
.background(underlineColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||
if (firstUnwatchedIndex != 0) {
|
||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
EpisodeCard(episode = episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesHeroSection(
|
||||
series: Series,
|
||||
nextUpEpisode: Episode?,
|
||||
onPlayEpisode: (Episode) -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
firstContentFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
SeriesMetaChips(series = series)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (nextUpEpisode != null) {
|
||||
Text(
|
||||
text = nextUpEpisode.heroStatusText(),
|
||||
color = scheme.primary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = nextUpEpisode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}",
|
||||
color = mutedStrong,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = nextUpEpisode.playButtonText(),
|
||||
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.focusProperties { down = firstContentFocusRequester }
|
||||
.testTag(SeriesPlayButtonTag)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Choose a season below to start watching.",
|
||||
color = mutedStrong,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
episode: Episode
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { viewModel.onSelectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
) },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(scheme.surface)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(scheme.background.copy(alpha = 0.2f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayCircle,
|
||||
contentDescription = null,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.background(scheme.background.copy(alpha = 0.8f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = episode.runtime,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
if (episode.watched.not() && (episode.progress ?: 0.0) > 0) {
|
||||
MediaProgressBar(
|
||||
progress = (episode.progress ?: 0.0).toFloat().div(100),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
)
|
||||
} else {
|
||||
WatchStateIndicator(
|
||||
watched = episode.watched,
|
||||
started = (episode.progress ?: 0.0) > 0.0,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
) {
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
MediaCastRow(
|
||||
cast = cast,
|
||||
modifier = modifier,
|
||||
cardWidth = 84.dp,
|
||||
nameSize = 11.sp,
|
||||
roleSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Episode.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
|
||||
private fun Episode.heroStatusText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
series: SeriesDto,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(series.id) {
|
||||
viewModel.selectSeries(series.id)
|
||||
}
|
||||
|
||||
val series = viewModel.series.collectAsState()
|
||||
|
||||
val seriesData = series.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
SeriesScreenContent(
|
||||
series = seriesData,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
PurefinWaitingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesScreenContent(
|
||||
series: Series,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val firstContentFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||
withFrameNanos { }
|
||||
if (nextUpEpisode != null) {
|
||||
playFocusRequester.requestFocus()
|
||||
} else {
|
||||
firstContentFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = series.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||
modifier = it
|
||||
) {
|
||||
SeriesHeroSection(
|
||||
series = series,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
playFocusRequester = playFocusRequester,
|
||||
firstContentFocusRequester = firstContentFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = series.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
item {
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeason = it },
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
if (series.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = it) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
CastRow(cast = series.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(): Season {
|
||||
for (season in seasons) {
|
||||
if (season.episodes.any { !it.watched }) {
|
||||
return season
|
||||
}
|
||||
}
|
||||
return seasons.first()
|
||||
}
|
||||
|
||||
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
||||
season.episodes.firstOrNull { !it.watched }
|
||||
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||
@@ -1,131 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
|
||||
@Composable
|
||||
fun MediaMetaChip(
|
||||
text: String,
|
||||
background: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border: Color = Color.Transparent,
|
||||
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(background)
|
||||
.border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaCastRow(
|
||||
cast: List<CastMember>,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 96.dp,
|
||||
nameSize: TextUnit = 12.sp,
|
||||
roleSize: TextUnit = 10.sp
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(cast) { member ->
|
||||
Column(modifier = Modifier.width(cardWidth)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(4f / 5f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
if (member.imageUrl == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.surfaceVariant.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Person,
|
||||
contentDescription = null,
|
||||
tint = mutedStrong
|
||||
)
|
||||
}
|
||||
} else {
|
||||
PurefinAsyncImage(
|
||||
model = member.imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = member.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = nameSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = member.role,
|
||||
color = mutedStrong,
|
||||
fontSize = roleSize,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnit.Companion.Unspecified
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun MediaSynopsis(
|
||||
synopsis: String,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Synopsis",
|
||||
titleColor: Color = MaterialTheme.colorScheme.onBackground,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
titleFontSize: TextUnit = 18.sp,
|
||||
bodyFontSize: TextUnit = 15.sp,
|
||||
bodyLineHeight: TextUnit? = 22.sp,
|
||||
titleSpacing: Dp = 12.dp,
|
||||
collapsedLines: Int = 3,
|
||||
collapseInitially: Boolean = true
|
||||
) {
|
||||
var isExpanded by remember(synopsis) { mutableStateOf(!collapseInitially) }
|
||||
var isOverflowing by remember(synopsis) { mutableStateOf(false) }
|
||||
|
||||
val containerModifier = if (isOverflowing) {
|
||||
modifier.clickable(role = Role.Button) { isExpanded = !isExpanded }
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
|
||||
Column(modifier = containerModifier) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = titleFontSize,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(titleSpacing))
|
||||
Text(
|
||||
text = synopsis,
|
||||
color = bodyColor,
|
||||
fontSize = bodyFontSize,
|
||||
lineHeight = bodyLineHeight ?: Unspecified,
|
||||
maxLines = if (isExpanded) Int.MAX_VALUE else collapsedLines,
|
||||
overflow = if (isExpanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||
onTextLayout = { result ->
|
||||
val overflowed = if (isExpanded) {
|
||||
result.lineCount > collapsedLines
|
||||
} else {
|
||||
result.hasVisualOverflow || result.lineCount > collapsedLines
|
||||
}
|
||||
if (overflowed != isOverflowing) {
|
||||
isOverflowing = overflowed
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun GhostIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
|
||||
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(52.dp)
|
||||
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f))
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MediaActionButton(
|
||||
backgroundColor: Color,
|
||||
iconColor: Color,
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
|
||||
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(height)
|
||||
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor.copy(alpha = 0.6f))
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MediaPlayButton(
|
||||
backgroundColor: Color,
|
||||
foregroundColor: Color,
|
||||
text: String = "Play",
|
||||
subText: String? = null,
|
||||
size: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(size)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundColor)
|
||||
.padding(start = 16.dp, end = 32.dp)
|
||||
.clickable { onClick() },
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
tint = foregroundColor,
|
||||
modifier = Modifier.size(42.dp)
|
||||
)
|
||||
Column() {
|
||||
Text(
|
||||
text = text,
|
||||
color = foregroundColor,
|
||||
fontSize = TextUnit(
|
||||
value = 16f,
|
||||
type = TextUnitType.Sp
|
||||
)
|
||||
)
|
||||
subText?.let {
|
||||
Text(
|
||||
text = subText,
|
||||
color = foregroundColor.copy(alpha = 0.7f),
|
||||
fontSize = TextUnit(
|
||||
value = 14f,
|
||||
type = TextUnitType.Sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
/**
|
||||
* Async image that falls back to theme-synced color blocks so loading/error states
|
||||
* stay aligned with PurefinTheme's colorScheme.
|
||||
*/
|
||||
@Composable
|
||||
fun PurefinAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop
|
||||
) {
|
||||
val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant)
|
||||
|
||||
// Convert empty string to null to properly trigger fallback
|
||||
val effectiveModel = when {
|
||||
model is String && model.isEmpty() -> null
|
||||
else -> model
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = effectiveModel,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
placeholder = placeholderPainter,
|
||||
error = placeholderPainter,
|
||||
fallback = placeholderPainter
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PurefinIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Int = 52
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(size.dp)
|
||||
.border(if (isFocused) 2.5.dp else 0.dp, if (isFocused) scheme.onPrimary else Color.Transparent, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(if (isFocused) scheme.primary else scheme.secondary)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package hu.bbara.purefin.login.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.PurefinComplexTextField
|
||||
import hu.bbara.purefin.common.ui.PurefinPasswordField
|
||||
import hu.bbara.purefin.common.ui.PurefinTextButton
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.feature.shared.login.LoginViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe ViewModel state
|
||||
val serverUrl by viewModel.url.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
var isLoggingIn by remember { mutableStateOf(false) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Logo Section
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie,
|
||||
contentDescription = "Logo",
|
||||
tint = scheme.onPrimary,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Jellyfin",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "PERSONAL MEDIA SYSTEM",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Form Section
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.align(Alignment.Start)
|
||||
)
|
||||
Text(
|
||||
text = "Enter your details to access your library",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = scheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
scheme.errorContainer,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Server URL",
|
||||
value = serverUrl,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUrl(it)
|
||||
},
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Username",
|
||||
value = username,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinPasswordField(
|
||||
label = "Password",
|
||||
value = password,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
placeholder = "••••••••",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text("Connect") },
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Footer Links
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = {}) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
Text(" Discover Servers", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {}) {
|
||||
Text("Need Help?", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.tv.navigation
|
||||
package hu.bbara.purefin.modules
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import dagger.Module
|
||||
@@ -6,7 +6,15 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.navigation.tvEpisodeSection
|
||||
import hu.bbara.purefin.navigation.tvHomeSection
|
||||
import hu.bbara.purefin.navigation.tvLibrarySection
|
||||
import hu.bbara.purefin.navigation.tvLoginSection
|
||||
import hu.bbara.purefin.navigation.tvMovieSection
|
||||
import hu.bbara.purefin.navigation.tvPlayerSection
|
||||
import hu.bbara.purefin.navigation.tvSettingsSection
|
||||
import hu.bbara.purefin.navigation.tvSeriesSection
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityRetainedComponent::class)
|
||||
@@ -53,4 +61,10 @@ object TvNavigationModule {
|
||||
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvLibrarySection()
|
||||
}
|
||||
|
||||
@IntoSet
|
||||
@Provides
|
||||
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvSettingsSection()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package hu.bbara.purefin.tv.di
|
||||
package hu.bbara.purefin.modules
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
|
||||
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -0,0 +1,12 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
|
||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||
|
||||
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }
|
||||
@@ -0,0 +1,74 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
|
||||
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
|
||||
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
|
||||
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||
import hu.bbara.purefin.ui.screen.TvAppScreen
|
||||
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||
|
||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
entry<Route.Home> {
|
||||
TvAppScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||
entry<Route.LoginRoute> {
|
||||
TvLoginScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvMovieSection() {
|
||||
entry<Route.MovieRoute> { route ->
|
||||
TvMovieScreen(movie = route.item)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvSeriesSection() {
|
||||
entry<Route.SeriesRoute> { route ->
|
||||
TvSeriesScreen(series = route.item)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
||||
entry<Route.EpisodeRoute> { route ->
|
||||
TvSeriesScreen(
|
||||
series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
|
||||
focusedSeasonId = route.item.seasonId,
|
||||
focusedEpisodeId = route.item.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvPlayerSection() {
|
||||
entry<Route.PlayerRoute> { route ->
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
TvPlayerScreen(
|
||||
mediaId = route.mediaId,
|
||||
onBack = { navigationManager.pop() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvLibrarySection() {
|
||||
entry<Route.LibraryRoute> { route ->
|
||||
val viewModel: AppViewModel = hiltViewModel()
|
||||
TvLibraryScreen(
|
||||
library = route.library,
|
||||
onMediaSelected = viewModel::onMediaSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvSettingsSection() {
|
||||
entry<Route.SettingsRoute> {
|
||||
TvSettingsScreen()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package hu.bbara.purefin.tv
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -11,15 +13,20 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
@@ -36,16 +43,19 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.theme.backgroundDark
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -66,7 +76,7 @@ class TvActivity : ComponentActivity() {
|
||||
lateinit var navigationManager: NavigationManager
|
||||
|
||||
@Inject
|
||||
lateinit var jellyfinApiClient: JellyfinApiClient
|
||||
lateinit var sessionBootstrapper: SessionBootstrapper
|
||||
|
||||
@Inject
|
||||
lateinit var authInterceptor: JellyfinAuthInterceptor
|
||||
@@ -94,7 +104,7 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private suspend fun init() {
|
||||
jellyfinApiClient.updateApiClient()
|
||||
sessionBootstrapper.initialize()
|
||||
}
|
||||
|
||||
private fun configureImageLoader() {
|
||||
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
|
||||
fun MainApp(
|
||||
userSessionRepository: UserSessionRepository,
|
||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||
navigationManager: NavigationManager
|
||||
navigationManager: NavigationManager,
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.checkForUpdatesOnAppOpen()
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
LaunchedEffect(updateViewModel, context) {
|
||||
updateViewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
val appEntryProvider = entryProvider {
|
||||
@@ -161,7 +185,13 @@ class TvActivity : ComponentActivity() {
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
NavigationCommand.Pop -> {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
@@ -171,13 +201,23 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
LocalNavigationBackStack provides backStack.toList()
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
navigationManager.pop()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LoginScreen()
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
TvLoginScreen()
|
||||
}
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
TvUpdateAvailableDialog(
|
||||
update = update,
|
||||
onAccept = updateViewModel::acceptUpdate,
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitAppDialog(
|
||||
onCloseConfirmed = { finishAndRemoveTask() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvUpdateAvailableDialog(
|
||||
update: AppUpdateInfo,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit
|
||||
) {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||
val updateText = if (releaseNotes == null) {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||
} else {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = { Text("Update available") },
|
||||
text = { Text(updateText) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAccept) {
|
||||
Text("Install")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDecline) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import hu.bbara.purefin.feature.shared.home.AppViewModel
|
||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
|
||||
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
|
||||
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
|
||||
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
@Composable
|
||||
fun TvAppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
val libraries by viewModel.libraries.collectAsState()
|
||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||
val nextUp by viewModel.nextUp.collectAsState()
|
||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
||||
|
||||
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) }
|
||||
|
||||
val destinations = remember(libraries, selectedDestination) {
|
||||
listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home,
|
||||
selected = selectedDestination == TvDrawerDestination.HOME
|
||||
),
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.LIBRARIES,
|
||||
label = "Libraries",
|
||||
icon = when {
|
||||
libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
|
||||
libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
|
||||
else -> Icons.Outlined.Collections
|
||||
},
|
||||
selected = selectedDestination == TvDrawerDestination.LIBRARIES
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.onResumed()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
TvNavigationDrawer(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
selectedDestination = destination
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
when (selectedDestination) {
|
||||
TvDrawerDestination.HOME -> {
|
||||
TvHomeScreen(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
serverUrl = serverUrl,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
TvDrawerDestination.LIBRARIES -> {
|
||||
TvLibrariesOverviewScreen(
|
||||
libraries = libraries,
|
||||
onLibrarySelected = { library ->
|
||||
viewModel.onLibrarySelected(library.id, library.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@ package hu.bbara.purefin.tv
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||
|
||||
@HiltAndroidApp
|
||||
class TvApplication : Application()
|
||||
class TvApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PurefinLogger.initialize(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeContent
|
||||
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
private const val TvHomeHeroHeightFraction = 0.32f
|
||||
private val TvHomeMinHeroHeight = 160.dp
|
||||
private val TvHomeMaxHeroHeight = 200.dp
|
||||
|
||||
@Composable
|
||||
fun TvHomeScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
serverUrl: String,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val heroState = rememberTvHomeHeroState(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
val heroHeight = heroState.focusedHero?.let {
|
||||
(maxHeight * TvHomeHeroHeightFraction)
|
||||
.coerceIn(TvHomeMinHeroHeight, TvHomeMaxHeroHeight)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
heroState.focusedHero?.let { hero ->
|
||||
TvFocusedItemHero(
|
||||
item = hero,
|
||||
height = heroHeight ?: TvHomeMinHeroHeight
|
||||
)
|
||||
}
|
||||
TvHomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaFocused = heroState.onMediaFocused,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class TvFocusedHeroModel(
|
||||
val id: UUID,
|
||||
val backdropImageUrl: String,
|
||||
val title: String,
|
||||
val metadata: List<String>,
|
||||
) {
|
||||
val metadataText: String?
|
||||
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString(" • ")
|
||||
}
|
||||
|
||||
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return when (this) {
|
||||
is ContinueWatchingItem -> when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||
else -> unsupportedType(type)
|
||||
}
|
||||
|
||||
is NextUpItem -> episode.toTvFocusedHeroModel()
|
||||
is PosterItem -> when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel()
|
||||
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel()
|
||||
else -> unsupportedType(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
|
||||
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
||||
return backdropImageUrl.ifBlank { fallbackImageUrl }
|
||||
}
|
||||
|
||||
private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return TvFocusedHeroModel(
|
||||
id = id,
|
||||
backdropImageUrl = backdropImageUrl(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
title = title,
|
||||
metadata = listOf(year, rating, runtime, format).compactMetadata(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
return TvFocusedHeroModel(
|
||||
id = id,
|
||||
backdropImageUrl = backdropImageUrl(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
title = title,
|
||||
metadata = listOf(
|
||||
"Episode $index",
|
||||
releaseDate,
|
||||
runtime,
|
||||
rating,
|
||||
format
|
||||
).compactMetadata(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
|
||||
val unwatchedText = if (unwatchedEpisodeCount > 0) {
|
||||
"$unwatchedEpisodeCount unwatched"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return TvFocusedHeroModel(
|
||||
id = id,
|
||||
backdropImageUrl = backdropImageUrl(
|
||||
imageUrlPrefix = imageUrlPrefix,
|
||||
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
),
|
||||
title = name,
|
||||
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun progressFraction(progress: Double?): Float? {
|
||||
val fraction = progress
|
||||
?.div(100.0)
|
||||
?.toFloat()
|
||||
?.coerceIn(0f, 1f)
|
||||
|
||||
return fraction?.takeIf { it > 0f }
|
||||
}
|
||||
|
||||
private fun progressLabel(progressFraction: Float?): String? {
|
||||
return progressFraction?.let { "${(it * 100).roundToInt()}%" }
|
||||
}
|
||||
|
||||
private fun List<String?>.compactMetadata(): List<String> {
|
||||
return map(String?::orEmpty)
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
}
|
||||
|
||||
private fun seasonLabel(seasonCount: Int): String {
|
||||
return if (seasonCount == 1) "1 season" else "$seasonCount seasons"
|
||||
}
|
||||
|
||||
private fun unsupportedType(type: BaseItemKind): Nothing {
|
||||
throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
|
||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: TvFocusedHeroModel,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = item.backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-background"
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.06f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0f),
|
||||
0.56f to scheme.background.copy(alpha = 0.1f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Crossfade(
|
||||
targetState = item,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-content"
|
||||
) { hero ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 40.dp, vertical = 18.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = 720.dp)
|
||||
) {
|
||||
Text(
|
||||
text = hero.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||
)
|
||||
hero.metadataText?.let { metadataText ->
|
||||
Text(
|
||||
text = metadataText,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
||||
|
||||
@Composable
|
||||
fun TvHomeContent(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
onMediaFocused: (FocusableItem) -> Unit,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val itemRegistry = rememberTvHomeItemRegistry(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp
|
||||
)
|
||||
val visibleLibraries = itemRegistry.visibleLibraries
|
||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||
val hasNextUp = nextUp.isNotEmpty()
|
||||
val hasLibraries = visibleLibraries.isNotEmpty()
|
||||
val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
|
||||
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
|
||||
val initialFocusRequester = remember { FocusRequester() }
|
||||
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
|
||||
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||
val columnBringIntoViewSpec = remember(topOffsetPx) {
|
||||
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
|
||||
}
|
||||
|
||||
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
|
||||
if (!initialFocusApplied && firstAvailableItemKey != null) {
|
||||
withFrameNanos { }
|
||||
initialFocusRequester.requestFocus()
|
||||
initialFocusApplied = true
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.testTag(TvHomeContentViewportTag),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (hasContinueWatching) {
|
||||
item {
|
||||
TvContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
firstItemFocusRequester = initialFocusRequester,
|
||||
firstItemTestTag = TvHomeInitialFocusTag,
|
||||
rowTestTag = TvHomeContinueWatchingRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContinueWatching && (hasNextUp || hasLibraries)) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNextUp) {
|
||||
item {
|
||||
TvNextUpSection(
|
||||
items = nextUp,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
|
||||
rowTestTag = TvHomeNextUpRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLibraries && (hasContinueWatching || hasNextUp)) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = visibleLibraries,
|
||||
key = { _, library -> library.id }
|
||||
) { index, library ->
|
||||
TvLibraryPosterSection(
|
||||
title = library.name,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onFocusedItem = onMediaFocused,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf {
|
||||
!hasContinueWatching &&
|
||||
!hasNextUp &&
|
||||
library.id == firstVisibleLibraryId
|
||||
},
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
|
||||
!hasContinueWatching &&
|
||||
!hasNextUp &&
|
||||
library.id == firstVisibleLibraryId
|
||||
},
|
||||
rowTestTag = tvHomeLibraryRowTag(library.id),
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
|
||||
if (index < visibleLibraries.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (hasVisibleContent) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
internal data class TvHomeItemRegistry(
|
||||
val visibleLibraries: List<LibraryItem>,
|
||||
private val libraryContent: Map<UUID, List<PosterItem>>,
|
||||
private val continueWatching: List<ContinueWatchingItem>,
|
||||
private val nextUp: List<NextUpItem>,
|
||||
val firstAvailableItemId: UUID?,
|
||||
) {
|
||||
val firstAvailableItem: FocusableItem?
|
||||
get() = firstAvailableItemId?.let(::itemById)
|
||||
|
||||
fun itemById(id: UUID): FocusableItem? {
|
||||
return continueWatching.firstOrNull { it.id == id }
|
||||
?: nextUp.firstOrNull { it.id == id }
|
||||
?: visibleLibraries.asSequence()
|
||||
.mapNotNull { library -> libraryContent[library.id] }
|
||||
.flatten()
|
||||
.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
internal class TvHomeHeroState(
|
||||
val focusedHero: TvFocusedHeroModel?,
|
||||
val onMediaFocused: (FocusableItem) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberTvHomeItemRegistry(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
): TvHomeItemRegistry {
|
||||
return remember(libraries, libraryContent, continueWatching, nextUp) {
|
||||
createTvHomeItemRegistry(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createTvHomeItemRegistry(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
): TvHomeItemRegistry {
|
||||
val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true }
|
||||
val firstAvailableItemId = continueWatching.firstOrNull()?.id
|
||||
?: nextUp.firstOrNull()?.id
|
||||
?: visibleLibraries.firstOrNull()?.id?.let { libraryId ->
|
||||
libraryContent[libraryId]?.firstOrNull()?.id
|
||||
}
|
||||
|
||||
return TvHomeItemRegistry(
|
||||
visibleLibraries = visibleLibraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
firstAvailableItemId = firstAvailableItemId
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberTvHomeHeroState(
|
||||
libraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>,
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
): TvHomeHeroState {
|
||||
val itemRegistry = rememberTvHomeItemRegistry(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp
|
||||
)
|
||||
var focusedItemId by remember { mutableStateOf<UUID?>(null) }
|
||||
val focusedItem = remember(focusedItemId, itemRegistry) {
|
||||
focusedItemId?.let(itemRegistry::itemById)
|
||||
}
|
||||
val focusedHero = remember(focusedItem, itemRegistry) {
|
||||
(focusedItem ?: itemRegistry.firstAvailableItem)?.toTvFocusedHeroModel()
|
||||
}
|
||||
val onMediaFocused: (FocusableItem) -> Unit = remember {
|
||||
{ item ->
|
||||
focusedItemId = item.id
|
||||
}
|
||||
}
|
||||
|
||||
return remember(focusedHero, onMediaFocused) {
|
||||
TvHomeHeroState(
|
||||
focusedHero = focusedHero,
|
||||
onMediaFocused = onMediaFocused
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
enum class TvDrawerDestination {
|
||||
HOME,
|
||||
LIBRARIES
|
||||
}
|
||||
|
||||
data class TvDrawerDestinationItem(
|
||||
val destination: TvDrawerDestination,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val selected: Boolean = false
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
package hu.bbara.purefin.tv.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import hu.bbara.purefin.app.content.episode.EpisodeScreen
|
||||
import hu.bbara.purefin.app.content.movie.MovieScreen
|
||||
import hu.bbara.purefin.app.content.series.SeriesScreen
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.login.ui.LoginScreen
|
||||
import hu.bbara.purefin.tv.TvAppScreen
|
||||
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen
|
||||
import hu.bbara.purefin.tv.player.TvPlayerScreen
|
||||
|
||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
entry<Route.Home> {
|
||||
TvAppScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvMovieSection() {
|
||||
entry<Route.MovieRoute> { route ->
|
||||
MovieScreen(movie = route.item)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvSeriesSection() {
|
||||
entry<Route.SeriesRoute> { route ->
|
||||
SeriesScreen(series = route.item)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
||||
entry<Route.EpisodeRoute> { route ->
|
||||
EpisodeScreen(episode = route.item)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvPlayerSection() {
|
||||
entry<Route.PlayerRoute> { route ->
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
TvPlayerScreen(
|
||||
mediaId = route.mediaId,
|
||||
onBack = { navigationManager.pop() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun EntryProviderScope<Route>.tvLibrarySection() {
|
||||
entry<Route.LibraryRoute> { route ->
|
||||
TvLibraryScreen(library = route.library)
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay
|
||||
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.tv.player.components.TvPlayerQueuePanel
|
||||
import hu.bbara.purefin.tv.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun TvPlayerScreen(
|
||||
mediaId: String,
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(mediaId) {
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||
var showQueuePanel by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = controlsVisible) {
|
||||
viewModel.toggleControlsVisibility()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying && controlsVisible) {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val showTvControls: () -> Unit = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
}
|
||||
val nextAndShowControls: () -> Unit = {
|
||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val previousAndShowControls: () -> Unit = {
|
||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
hiddenControlFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(hiddenControlFocusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (!controlsVisible && event.type == KeyEventType.KeyDown) {
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
seekByAndShowControls(-10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
seekByAndShowControls(10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
showTvControls()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
togglePlayPauseAndShowControls()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
player = viewModel.player
|
||||
}
|
||||
},
|
||||
update = { it.player = viewModel.player },
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible || uiState.isEnded || uiState.error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TvPlayerControlsOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||
onNext = nextAndShowControls,
|
||||
onPrevious = previousAndShowControls,
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
onOpenQueue = { showQueuePanel = true }
|
||||
)
|
||||
}
|
||||
|
||||
TvPlayerLoadingErrorEndCard(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = nextAndShowControls,
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
togglePlayPauseAndShowControls()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = trackPanelType != null,
|
||||
enter = slideInHorizontally { it },
|
||||
exit = slideOutHorizontally { it }
|
||||
) {
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
uiState = uiState,
|
||||
onSelect = { track ->
|
||||
viewModel.selectTrack(track)
|
||||
trackPanelType = null
|
||||
},
|
||||
onClose = { trackPanelType = null },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showQueuePanel,
|
||||
enter = slideInHorizontally { it },
|
||||
exit = slideOutHorizontally { it }
|
||||
) {
|
||||
TvPlayerQueuePanel(
|
||||
uiState = uiState,
|
||||
onSelect = { id ->
|
||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
||||
showQueuePanel = false
|
||||
},
|
||||
onClose = { showQueuePanel = false },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun TvIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
size: Int = 52,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.1f else 1.0f,
|
||||
label = "scale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||
label = "border"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.widthIn(min = size.dp)
|
||||
.height(size.dp)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.5f)
|
||||
else scheme.background.copy(alpha = 0.65f)
|
||||
)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.PlaylistPlay
|
||||
import androidx.compose.material.icons.outlined.Forward30
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Replay10
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
TvPlayerTopBar(
|
||||
title = uiState.title ?: "Playing",
|
||||
subtitle = uiState.subtitle,
|
||||
onOpenQueue = onOpenQueue,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
TvPlayerBottomSection(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
onOpenAudioPanel = onOpenAudioPanel,
|
||||
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
|
||||
onOpenQualityPanel = onOpenQualityPanel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerTopBar(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = scheme.onBackground.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
TvIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.PlaylistPlay,
|
||||
contentDescription = "Queue",
|
||||
onClick = onOpenQueue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerBottomSection(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(uiState.positionMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (uiState.isLive) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "LIVE",
|
||||
color = scheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Catch up",
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.clickable { onSeekLiveEdge() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = formatTime(uiState.durationMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
TvPlayerSeekBar(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
togglePlayState = onPlayPause,
|
||||
focusRequester = focusRequester
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
onClick = onPrevious,
|
||||
size = 64
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
onClick = { onSeekRelative(-10_000) },
|
||||
size = 64
|
||||
)
|
||||
TvIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
onClick = onPlayPause,
|
||||
size = 72
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Forward30,
|
||||
contentDescription = "Seek forward 30 seconds",
|
||||
onClick = { onSeekRelative(30_000) },
|
||||
size = 64
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
onClick = onNext,
|
||||
size = 64
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvQualitySelectionButton(onClick = onOpenQualityPanel)
|
||||
TvAudioSelectionButton(onClick = onOpenAudioPanel)
|
||||
TvSubtitlesSelectionButton(onClick = onOpenSubtitlesPanel)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(positionMs: Long): String {
|
||||
val totalSeconds = positionMs / 1000
|
||||
val seconds = (totalSeconds % 60).toInt()
|
||||
val minutes = ((totalSeconds / 60) % 60).toInt()
|
||||
val hours = (totalSeconds / 3600).toInt()
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerQueuePanel(
|
||||
uiState: PlayerUiState,
|
||||
onSelect: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(320.dp)
|
||||
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
|
||||
color = scheme.surface.copy(alpha = 0.97f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Up next",
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
onClick = onClose
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = uiState.queue.isNotEmpty()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(uiState.queue) { item ->
|
||||
TvQueueRow(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = item.isCurrent,
|
||||
onClick = { onSelect(item.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.queue.isEmpty()) {
|
||||
Text(
|
||||
text = "No items in queue",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvQueueRow(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
artworkUrl: String?,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.35f)
|
||||
else if (isCurrent) scheme.primary.copy(alpha = 0.15f)
|
||||
else scheme.surfaceVariant.copy(alpha = 0.8f)
|
||||
)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
if (artworkUrl != null) {
|
||||
PurefinAsyncImage(
|
||||
model = artworkUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(4f / 3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
subtitle?.let { value ->
|
||||
Text(
|
||||
text = value,
|
||||
color = scheme.onSurface.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.MarkerType
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerSeekBar(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
togglePlayState: () -> Unit,
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
val position = positionMs.coerceIn(0, safeDuration)
|
||||
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
|
||||
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
|
||||
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
|
||||
adMarkers.map { it.copy(type = MarkerType.AD) }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.height(32.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
onSeekRelative(-10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onSeekRelative(10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.Enter -> {
|
||||
togglePlayState()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.focusable(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 2.dp, vertical = 10.dp)
|
||||
) {
|
||||
val trackHeight = if (isFocused) 6f else 4f
|
||||
val trackTop = size.height / 2 - trackHeight / 2
|
||||
drawRect(
|
||||
color = scheme.onSurface.copy(alpha = 0.2f),
|
||||
size = Size(width = size.width, height = trackHeight),
|
||||
topLeft = Offset(0f, trackTop)
|
||||
)
|
||||
drawRect(
|
||||
color = scheme.onSurface.copy(alpha = 0.4f),
|
||||
size = Size(width = bufferRatio * size.width, height = trackHeight),
|
||||
topLeft = Offset(0f, trackTop)
|
||||
)
|
||||
val progressWidth = progressRatio * size.width
|
||||
drawRect(
|
||||
color = scheme.primary,
|
||||
size = Size(width = progressWidth, height = trackHeight),
|
||||
topLeft = Offset(0f, trackTop)
|
||||
)
|
||||
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
|
||||
val thumbCenter = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
|
||||
drawCircle(
|
||||
color = scheme.primary,
|
||||
radius = thumbRadius,
|
||||
center = thumbCenter
|
||||
)
|
||||
if (isFocused) {
|
||||
drawCircle(
|
||||
color = scheme.primary.copy(alpha = 0.3f),
|
||||
radius = thumbRadius + 4.dp.toPx(),
|
||||
center = thumbCenter
|
||||
)
|
||||
}
|
||||
combinedMarkers.forEach { marker ->
|
||||
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
|
||||
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - 1f, size.height / 2 - 6f),
|
||||
size = Size(width = 2f, height = 12f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package hu.bbara.purefin.tv.player.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.ClosedCaption
|
||||
import androidx.compose.material.icons.outlined.HighQuality
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
|
||||
internal enum class TvTrackPanelType {
|
||||
AUDIO,
|
||||
SUBTITLES,
|
||||
QUALITY
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvQualitySelectionButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.HighQuality,
|
||||
contentDescription = "Quality",
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvAudioSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Language,
|
||||
contentDescription = "Audio",
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvSubtitlesSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.ClosedCaption,
|
||||
contentDescription = "Subtitles",
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvTrackSelectionPanel(
|
||||
panelType: TvTrackPanelType,
|
||||
uiState: PlayerUiState,
|
||||
onSelect: (TrackOption) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (title, options, selectedId) = when (panelType) {
|
||||
TvTrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
|
||||
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
|
||||
TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(320.dp)
|
||||
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
|
||||
color = scheme.surface.copy(alpha = 0.97f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Close",
|
||||
onClick = onClose
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 500.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
TvTrackOptionRow(
|
||||
label = option.label,
|
||||
selected = option.id == selectedId,
|
||||
onClick = { onSelect(option) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvTrackOptionRow(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.3f)
|
||||
else if (selected) scheme.primary.copy(alpha = 0.15f)
|
||||
else scheme.surfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package hu.bbara.purefin.ui.common.card
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
item: MediaUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier = Modifier,
|
||||
posterWidth: Dp = 144.dp,
|
||||
showSecondaryText: Boolean = false,
|
||||
indicatorSize: Int = 28,
|
||||
indicatorPadding: Dp = 8.dp,
|
||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
PosterCardContent(
|
||||
model = item,
|
||||
onClick = {
|
||||
// TODO Fix it by addig a handler into the viewModel like onMediaUiModelSelected
|
||||
when (item) {
|
||||
is SeriesUiModel -> onSeriesSelected(item.id)
|
||||
is MovieUiModel -> onMovieSelected(item.id)
|
||||
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
imageModifier = imageModifier,
|
||||
posterWidth = posterWidth,
|
||||
showSecondaryText = showSecondaryText,
|
||||
indicatorSize = indicatorSize,
|
||||
indicatorPadding = indicatorPadding,
|
||||
onFocused = { onFocusedItem(item) },
|
||||
focusedScale = 1.07f,
|
||||
focusedBorderWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PosterCardContent(
|
||||
model: MediaUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier = Modifier,
|
||||
posterWidth: Dp = 144.dp,
|
||||
showSecondaryText: Boolean = false,
|
||||
indicatorSize: Int = 28,
|
||||
indicatorPadding: Dp = 8.dp,
|
||||
onFocused: () -> Unit,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBorderWidth: Dp = 1.dp,
|
||||
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) focusedScale else 1f,
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(posterWidth)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = focusedTransformOrigin
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
PurefinAsyncImage(
|
||||
model = model.primaryImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = imageModifier
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.border(
|
||||
width = if (isFocused) focusedBorderWidth else 1.dp,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.background(scheme.surfaceVariant)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocused()
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
when (model) {
|
||||
is MovieUiModel, is EpisodeUiModel -> {
|
||||
WatchStateBadge(
|
||||
size = indicatorSize,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(indicatorPadding),
|
||||
watched = model.watched,
|
||||
)
|
||||
}
|
||||
is SeriesUiModel -> {
|
||||
UnwatchedEpisodeBadge(
|
||||
unwatchedCount = model.unwatchedEpisodeCount,
|
||||
size = 26,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(indicatorPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
top = 8.dp,
|
||||
start = 4.dp,
|
||||
end = 4.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = model.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
model.secondaryText
|
||||
.takeIf { showSecondaryText }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
package hu.bbara.purefin.ui.common.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
@@ -15,14 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -31,13 +27,13 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
|
||||
internal val MediaDetailHorizontalPadding = 48.dp
|
||||
private val MediaDetailHeaderTopPadding = 104.dp
|
||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
||||
private val MediaDetailHeaderTopPadding = 6.dp
|
||||
private val MediaDetailHeaderBottomPadding = 4.dp
|
||||
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||
|
||||
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||
@@ -61,19 +57,13 @@ internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||
internal fun TvMediaDetailScaffold(
|
||||
resetScrollKey: Any,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
||||
bodyContent: @Composable (Modifier) -> Unit = {}
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(resetScrollKey) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
@@ -87,7 +77,7 @@ internal fun TvMediaDetailScaffold(
|
||||
internal fun TvMediaDetailBodyBox(
|
||||
backgroundImageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = 0.48f,
|
||||
heightFraction: Float = 0.24f,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -99,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = bodyHeight)
|
||||
) {
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = backgroundImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -151,8 +143,8 @@ internal fun TvMediaDetailBodyBox(
|
||||
}
|
||||
|
||||
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
||||
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
||||
val primaryImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
|
||||
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
|
||||
return backdropImageUrl.ifBlank { primaryImageUrl }
|
||||
}
|
||||
|
||||
@@ -183,12 +175,11 @@ internal fun MediaDetailOverviewSection(
|
||||
title = "Overview",
|
||||
titleColor = scheme.onBackground,
|
||||
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||
titleFontSize = 22.sp,
|
||||
bodyFontSize = 16.sp,
|
||||
bodyLineHeight = 24.sp,
|
||||
titleSpacing = 14.dp,
|
||||
collapsedLines = 5,
|
||||
collapseInitially = false
|
||||
titleFontSize = 15.sp,
|
||||
bodyFontSize = 11.sp,
|
||||
bodyLineHeight = 14.sp,
|
||||
titleSpacing = 2.dp,
|
||||
collapsedLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
package hu.bbara.purefin.ui.common.media
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||
|
||||
internal data class MediaDetailsTopBarShortcut(
|
||||
val label: String,
|
||||
@@ -53,6 +54,7 @@ internal fun MediaDetailsTopBar(
|
||||
backFocusRequester: FocusRequester? = null,
|
||||
downFocusRequester: FocusRequester? = null
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val downModifier = if (downFocusRequester != null) {
|
||||
Modifier.focusProperties { down = downFocusRequester }
|
||||
} else {
|
||||
@@ -77,7 +79,9 @@ internal fun MediaDetailsTopBar(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
modifier = backModifier.then(downModifier)
|
||||
modifier = backModifier.then(downModifier),
|
||||
focusedScale = 1.1f,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
if (shortcut != null) {
|
||||
GhostTextButton(
|
||||
@@ -92,13 +96,17 @@ internal fun MediaDetailsTopBar(
|
||||
icon = Icons.Outlined.Cast,
|
||||
contentDescription = "Cast",
|
||||
onClick = onCastClick,
|
||||
modifier = downModifier
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.MoreVert,
|
||||
contentDescription = "More",
|
||||
onClick = onMoreClick,
|
||||
modifier = downModifier
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
}
|
||||
}
|
||||
144
app-tv/src/main/java/hu/bbara/purefin/ui/screen/TvAppScreen.kt
Normal file
@@ -0,0 +1,144 @@
|
||||
package hu.bbara.purefin.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
|
||||
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||
|
||||
@Composable
|
||||
fun TvAppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
|
||||
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
|
||||
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
val selectedDestination = backStack.lastOrNull() ?: Route.Home
|
||||
|
||||
val destinations = remember(libraries) {
|
||||
listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = Route.Home,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home
|
||||
)
|
||||
) + libraries.map { library ->
|
||||
val destination = Route.LibraryRoute(
|
||||
library = LibraryDto(id = library.id, name = library.name)
|
||||
)
|
||||
TvDrawerDestinationItem(
|
||||
destination = destination,
|
||||
label = library.name,
|
||||
icon = when (library.type) {
|
||||
LibraryKind.MOVIES -> Icons.Outlined.Movie
|
||||
LibraryKind.SERIES -> Icons.Outlined.Tv
|
||||
}
|
||||
)
|
||||
} + TvDrawerDestinationItem(
|
||||
destination = Route.SettingsRoute,
|
||||
label = "Settings",
|
||||
icon = Icons.Outlined.Settings
|
||||
)
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.onResumed()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val tvEntryProvider = entryProvider {
|
||||
entry<Route.Home> {
|
||||
TvHomeScreen(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaSelected = viewModel::onMediaSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<Route.LibraryRoute> { route ->
|
||||
TvLibraryScreen(
|
||||
library = route.library,
|
||||
onMediaSelected = viewModel::onMediaSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<Route.SettingsRoute> {
|
||||
TvSettingsScreen(
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
TvNavigationDrawer(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
if (selectedDestination == Route.Home) {
|
||||
viewModel.onRefresh()
|
||||
}
|
||||
if (selectedDestination != destination) {
|
||||
backStack.clear()
|
||||
backStack.add(Route.Home)
|
||||
if (destination != Route.Home) {
|
||||
backStack.add(destination)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
navigationManager.pop()
|
||||
}
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = tvEntryProvider,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package hu.bbara.purefin.ui.screen.episode
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvEpisodeScreen(
|
||||
episode: EpisodeDto,
|
||||
viewModel: EpisodeScreenViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
|
||||
LaunchedEffect(episode) {
|
||||
viewModel.selectEpisode(episode)
|
||||
}
|
||||
|
||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||
val selectedEpisode = episode.value
|
||||
|
||||
if (selectedEpisode == null) {
|
||||
PurefinWaitingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
TvEpisodeScreenContent(
|
||||
episode = selectedEpisode,
|
||||
seriesTitle = selectedEpisode.seriesName,
|
||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||
{
|
||||
navigationManager.navigate(
|
||||
Route.PlayerRoute(mediaId = selectedEpisode.id.toString())
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvEpisodeScreenContent(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(episode.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = episode.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
|
||||
episode.imageUrlPrefix,
|
||||
ArtworkKind.PRIMARY
|
||||
),
|
||||
modifier = it
|
||||
) {
|
||||
TvEpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = episode.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
// MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
package hu.bbara.purefin.ui.screen.episode.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -20,15 +17,16 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.model.Episode
|
||||
|
||||
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun EpisodeHeroSection(
|
||||
internal fun TvEpisodeHeroSection(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
onPlay: () -> Unit,
|
||||
@@ -41,63 +39,59 @@ internal fun EpisodeHeroSection(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
) {
|
||||
if (!seriesTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = seriesTitle,
|
||||
color = scheme.primary,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = episode.releaseDate)
|
||||
MediaMetaChip(text = episode.rating)
|
||||
MediaMetaChip(text = episode.runtime)
|
||||
MediaMetaChip(
|
||||
text = episode.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.35f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||
highlightedItem = episode.format
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
MediaResumeButton(
|
||||
text = episode.playButtonText(),
|
||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(EpisodePlayButtonTag)
|
||||
.testTag(EpisodePlayButtonTag),
|
||||
focusedScale = 1.08f,
|
||||
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
|
||||
focusBorderWidth = 3.dp,
|
||||
focusBorderColor = scheme.onBackground,
|
||||
overlayBorderWidth = 2.dp,
|
||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||
focusable = true,
|
||||
height = 40.dp,
|
||||
textSize = 13.sp,
|
||||
iconSize = 20.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Episode.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package hu.bbara.purefin.ui.screen.home
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun TvHomeScreen(
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
nextUp: List<MediaUiModel>,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = scheme.background
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
TvHomeHeroBackdrop(
|
||||
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
TvFocusedItemHero(
|
||||
item = focusedMediaUiModel.value
|
||||
)
|
||||
TvHomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaFocused = {
|
||||
focusedMediaUiModel.value = it
|
||||
},
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
// The billboard backdrop covers the top portion of the home screen so the hero
|
||||
// text sits at the bottom of the backdrop and the content rows begin below it.
|
||||
internal const val TvHomeHeroHeightFraction = 0.45f
|
||||
internal const val TvHomeBackdropOffset = 0.20f
|
||||
|
||||
/**
|
||||
* The home screen background image plus the darkening gradients that keep the
|
||||
* hero text readable. Drawn behind the home content and sized to the top
|
||||
* [TvHomeHeroHeightFraction] of the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TvHomeHeroBackdrop(
|
||||
backdropImageUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Crossfade(
|
||||
targetState = backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-backdrop",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(heightFraction)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
// Darken the left side so the text block stays readable over any backdrop.
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||
0.34f to scheme.background.copy(alpha = 0.62f),
|
||||
0.66f to scheme.background.copy(alpha = 0.22f),
|
||||
1.0f to scheme.background.copy(alpha = 0.04f)
|
||||
)
|
||||
)
|
||||
)
|
||||
// Strong bottom fade so the title and metadata are legible.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0f),
|
||||
0.40f to scheme.background.copy(alpha = 0.10f),
|
||||
0.72f to scheme.background.copy(alpha = 0.58f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Crossfade(
|
||||
targetState = item,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-content",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(heightFraction)
|
||||
) { hero ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 40.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = 720.dp)
|
||||
) {
|
||||
Text(
|
||||
text = hero.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 44.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||
)
|
||||
TvHomeHeroMetadataRow(item = hero)
|
||||
if (hero.description.isNotBlank()) {
|
||||
Text(
|
||||
text = hero.description,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
||||
val hasSecondary = item.secondaryText.isNotBlank()
|
||||
val progress = item.progress
|
||||
val hasProgress = progress != null && progress > 0f && progress < 1f
|
||||
if (!hasSecondary && !hasProgress) return
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (hasSecondary) {
|
||||
TvHomeMetaChip(text = item.secondaryText)
|
||||
}
|
||||
if (hasProgress) {
|
||||
val percent = ((progress ?: 0f) * 100f).roundToInt()
|
||||
TvHomeMetaChip(
|
||||
text = "Resume \u00B7 $percent%",
|
||||
highlighted = true,
|
||||
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
|
||||
internal val TvHomeFocusedItemTopOffset = 56.dp
|
||||
internal val TvHomeFocusedItemTopOffset = 48.dp
|
||||
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
||||
|
||||
private const val TvHomeRowPivotParentFraction = 0.3f
|
||||
@@ -0,0 +1,108 @@
|
||||
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import java.util.UUID
|
||||
|
||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
||||
|
||||
@Composable
|
||||
fun TvHomeContent(
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
nextUp: List<MediaUiModel>,
|
||||
onMediaFocused: (MediaUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||
val hasNextUp = nextUp.isNotEmpty()
|
||||
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
|
||||
val hasVisibleContent = hasContinueWatching || hasNextUp
|
||||
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
|
||||
val initialFocusRequester = remember { FocusRequester() }
|
||||
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(hasInitialFocusableItem, initialFocusApplied) {
|
||||
if (!initialFocusApplied && hasInitialFocusableItem) {
|
||||
initialFocusRequester.requestFocus()
|
||||
initialFocusApplied = true
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides tvHomeColumnBringIntoViewSpec(topOffsetPx)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(initialFocusRequester),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
if (hasContinueWatching) {
|
||||
item(key = "tv-home-continue-watching") {
|
||||
TvContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMediaSelected = onMediaSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNextUp) {
|
||||
item(key = "tv-home-next-up") {
|
||||
TvNextUpSection(
|
||||
items = nextUp,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMediaSelected = onMediaSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = libraries,
|
||||
key = { _, library -> library.id }
|
||||
) { _, library ->
|
||||
TvLibraryPosterSection(
|
||||
title = library.name,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMediaSelected = onMediaSelected
|
||||
)
|
||||
}
|
||||
|
||||
if (hasVisibleContent) {
|
||||
item(key = "tv-home-trailing-space") {
|
||||
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -0,0 +1,10 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
|
||||
data class TvDrawerDestinationItem(
|
||||
val destination: Route,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
@@ -1,25 +1,28 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -28,237 +31,188 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.card.PosterCardContent
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
|
||||
private const val TvHomeMediaCardScale = 0.9f
|
||||
private val TvHomeSectionsThumbShape = RoundedCornerShape((20f * TvHomeMediaCardScale).dp)
|
||||
private val TvHomeSectionsHorizontalPadding = 32.dp
|
||||
private val TvHomeSectionsRowSpacing = 18.dp
|
||||
private val TvHomeLandscapeCardWidth = 248.dp
|
||||
private val TvHomePosterCardWidth = 136.dp
|
||||
private val TvHomeLandscapeCardWidth = (248f * TvHomeMediaCardScale).dp
|
||||
private val TvHomePosterCardWidth = (136f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataSpacing = (1f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataTopPadding = (6f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
|
||||
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
|
||||
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
|
||||
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
|
||||
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
|
||||
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
|
||||
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
||||
|
||||
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
||||
"${TvHomeSectionRowTagPrefix}library-$libraryId"
|
||||
|
||||
internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
|
||||
"${TvHomeSectionRowTagPrefix}library-item-$libraryId"
|
||||
|
||||
@Composable
|
||||
fun TvContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Continue Watching",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = when (item.type) {
|
||||
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||
prefixImageUrl = item.movie?.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
)
|
||||
|
||||
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
||||
prefixImageUrl = item.episode?.imageUrlPrefix,
|
||||
imageType = ImageType.PRIMARY
|
||||
)
|
||||
|
||||
else -> null
|
||||
},
|
||||
progress = progressFraction,
|
||||
imageModifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
onFocusedItem = { onFocusedItem(item) },
|
||||
onClick = {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = item.episode!!
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
progress = item.progress ?: 0f,
|
||||
imageModifier = focusModifier,
|
||||
onFocusedItem = {
|
||||
onFocused(item)
|
||||
},
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvNextUpSection(
|
||||
items: List<NextUpItem>,
|
||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Next Up",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.imageUrl,
|
||||
imageModifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
onFocusedItem = { onFocusedItem(item) },
|
||||
onClick = {
|
||||
val episode = item.episode
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
imageModifier = focusModifier,
|
||||
onFocusedItem = { onFocused(item) },
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvLibraryPosterSection(
|
||||
title: String,
|
||||
items: List<PosterItem>,
|
||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = title,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
posterWidth = TvHomePosterCardWidth,
|
||||
showSecondaryText = true,
|
||||
indicatorSize = 24,
|
||||
indicatorPadding = 6.dp,
|
||||
imageModifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
onFocusedItem = onFocusedItem,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
PosterCardContent(
|
||||
model = item,
|
||||
onClick = { onMediaSelected(item) },
|
||||
onFocused = { onFocused(item) },
|
||||
posterWidth = TvHomePosterCardWidth,
|
||||
showSecondaryText = true,
|
||||
indicatorSize = TvHomePosterIndicatorSize,
|
||||
indicatorPadding = TvHomePosterIndicatorPadding,
|
||||
imageModifier = focusModifier,
|
||||
focusedScale = 1.07f,
|
||||
focusedBorderWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TvHomeSectionRow(
|
||||
items: List<MediaUiModel>,
|
||||
onFocused: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowTestTag: String? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
childComponent: @Composable (
|
||||
item: MediaUiModel,
|
||||
onFocused: (MediaUiModel) -> Unit,
|
||||
focusModifier: Modifier,
|
||||
) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var focusedItemId by rememberSaveable { mutableStateOf(items.first().id) }
|
||||
val rowScopedFocusRequester = remember { FocusRequester() }
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (rowTestTag != null) {
|
||||
Modifier.testTag(rowTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
.focusRestorer(rowScopedFocusRequester)
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
|
||||
content = content
|
||||
)
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
val focusModifier =
|
||||
if (item.id == focusedItemId) Modifier.focusRequester(rowScopedFocusRequester) else Modifier
|
||||
childComponent(
|
||||
item,
|
||||
{
|
||||
focusedItemId = item.id
|
||||
onFocused(item)
|
||||
},
|
||||
focusModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +251,10 @@ private fun TvHomeLandscapeCard(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale")
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.055f else 1f,
|
||||
label = "tv-home-landscape-scale"
|
||||
)
|
||||
val cardWidth = TvHomeLandscapeCardWidth
|
||||
|
||||
Column(
|
||||
@@ -316,7 +273,7 @@ private fun TvHomeLandscapeCard(
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(TvHomeSectionsThumbShape)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
width = if (isFocused) TvHomeLandscapeFocusedBorderWidth else TvHomeLandscapeUnfocusedBorderWidth,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
|
||||
shape = TvHomeSectionsThumbShape
|
||||
)
|
||||
@@ -355,21 +312,29 @@ private fun TvHomeLandscapeCard(
|
||||
foregroundColor = scheme.primary,
|
||||
backgroundColor = scheme.surfaceVariant,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
barHeight = 6.dp,
|
||||
barHeight = TvHomeLandscapeProgressBarHeight,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.padding(
|
||||
horizontal = TvHomeLandscapeProgressBarPadding,
|
||||
vertical = TvHomeLandscapeProgressBarPadding
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(TvHomeLandscapeMetadataSpacing),
|
||||
modifier = Modifier.padding(
|
||||
top = TvHomeLandscapeMetadataTopPadding,
|
||||
start = TvHomeLandscapeMetadataHorizontalPadding,
|
||||
end = TvHomeLandscapeMetadataHorizontalPadding
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontSize = TvHomeLandscapeTitleFontSize,
|
||||
lineHeight = TvHomeLandscapeTitleFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -378,7 +343,8 @@ private fun TvHomeLandscapeCard(
|
||||
Text(
|
||||
text = supporting,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = TvHomeLandscapeSupportingFontSize,
|
||||
lineHeight = TvHomeLandscapeSupportingFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -1,11 +1,10 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,47 +12,56 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.DrawerState
|
||||
import androidx.tv.material3.DrawerValue
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.NavigationDrawer
|
||||
import androidx.tv.material3.NavigationDrawerItem
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import androidx.tv.material3.rememberDrawerState
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.tv.R
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
|
||||
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
|
||||
internal const val TvDrawerTitleTag = "tv-drawer-title"
|
||||
|
||||
private val TvDrawerCollapsedWidth = 92.dp
|
||||
private val TvDrawerExpandedWidth = 280.dp
|
||||
|
||||
@Composable
|
||||
fun TvNavigationDrawer(
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
selectedDestination: Route,
|
||||
onDestinationSelected: (Route) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
ProvideTvDrawerTheme {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
||||
NavigationDrawer(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
TvNavigationDrawerRail(
|
||||
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
|
||||
drawerState = drawerState,
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected
|
||||
@@ -122,6 +130,8 @@ fun TvDrawerHeader(
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = "Purefin",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.testTag(TvDrawerTitleTag)
|
||||
)
|
||||
}
|
||||
@@ -130,60 +140,59 @@ fun TvDrawerHeader(
|
||||
|
||||
@Composable
|
||||
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||
drawerValue: DrawerValue,
|
||||
drawerState: DrawerState,
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
selectedDestination: Route,
|
||||
onDestinationSelected: (Route) -> Unit,
|
||||
) {
|
||||
val expanded = drawerValue == DrawerValue.Open
|
||||
val drawerWidth = animateDpAsState(
|
||||
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
|
||||
label = "tv-drawer-width"
|
||||
)
|
||||
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
|
||||
val expanded = drawerState.currentValue == DrawerValue.Open
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var hasDrawerFocus by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(drawerWidth.value)
|
||||
.fillMaxHeight()
|
||||
.background(scheme.surface.copy(alpha = 0.96f))
|
||||
.padding(horizontal = 12.dp, vertical = 24.dp)
|
||||
.selectableGroup()
|
||||
.focusGroup()
|
||||
.onFocusChanged { state ->
|
||||
hasDrawerFocus = state.isFocused
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
TvDrawerHeader(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = { onDestinationSelected(destination.destination) },
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (expanded) {
|
||||
Text(
|
||||
text = destination.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
TvDrawerHeader(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
onDestinationSelected(destination.destination)
|
||||
if (isSelected) {
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected }
|
||||
.focusProperties { canFocus = hasDrawerFocus || isSelected },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = destination.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin.tv.library.ui
|
||||
package hu.bbara.purefin.ui.screen.libraries
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -43,9 +42,9 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
|
||||
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
||||
|
||||
@@ -53,8 +52,8 @@ private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
|
||||
|
||||
@Composable
|
||||
fun TvLibrariesOverviewScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
libraries: List<LibraryUiModel>,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -118,7 +117,7 @@ fun TvLibrariesOverviewScreen(
|
||||
|
||||
@Composable
|
||||
private fun TvLibraryOverviewCard(
|
||||
item: LibraryItem,
|
||||
item: LibraryUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -161,9 +160,8 @@ private fun TvLibraryOverviewCard(
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = when (item.type) {
|
||||
CollectionType.MOVIES -> Icons.Outlined.Movie
|
||||
CollectionType.TVSHOWS -> Icons.Outlined.Tv
|
||||
else -> Icons.Outlined.Collections
|
||||
LibraryKind.MOVIES -> Icons.Outlined.Movie
|
||||
LibraryKind.SERIES -> Icons.Outlined.Tv
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant,
|
||||
@@ -1,37 +1,33 @@
|
||||
package hu.bbara.purefin.tv.library.ui
|
||||
package hu.bbara.purefin.ui.screen.library
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
import hu.bbara.purefin.common.ui.components.PurefinIconButton
|
||||
import hu.bbara.purefin.core.data.navigation.LibraryDto
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
@Composable
|
||||
fun TvLibraryScreen(
|
||||
library: LibraryDto,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -39,21 +35,19 @@ fun TvLibraryScreen(
|
||||
viewModel.selectLibrary(libraryId = library.id)
|
||||
}
|
||||
|
||||
val libraryItems = viewModel.contents.collectAsState()
|
||||
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TvLibraryTopBar(
|
||||
title = library.name,
|
||||
onBack = viewModel::onBack
|
||||
title = library.name
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
TvLibraryContent(
|
||||
libraryItems = libraryItems.value,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
@@ -61,35 +55,23 @@ fun TvLibraryScreen(
|
||||
|
||||
@Composable
|
||||
private fun TvLibraryTopBar(
|
||||
title: String,
|
||||
onBack: () -> Unit
|
||||
title: String
|
||||
) {
|
||||
Row(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PurefinIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvLibraryContent(
|
||||
libraryItems: List<PosterItem>,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
libraryItems: List<MediaUiModel>,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -100,12 +82,12 @@ fun TvLibraryContent(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
items(libraryItems) { item ->
|
||||
items(libraryItems, key = { item -> item.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
onMovieSelected = { onMediaSelected(item) },
|
||||
onSeriesSelected = { onMediaSelected(item) },
|
||||
onEpisodeSelected = { _, _, _ -> onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package hu.bbara.purefin.ui.screen.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.Text as TvText
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvLoginContent(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (state.isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
ProvideTvLoginTheme {
|
||||
TvLoginForm(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProvideTvLoginTheme(content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TvMaterialTheme(
|
||||
colorScheme = darkColorScheme(
|
||||
primary = scheme.primary,
|
||||
onPrimary = scheme.onPrimary,
|
||||
primaryContainer = scheme.primaryContainer,
|
||||
onPrimaryContainer = scheme.onPrimaryContainer,
|
||||
secondary = scheme.secondary,
|
||||
onSecondary = scheme.onSecondary,
|
||||
secondaryContainer = scheme.secondaryContainer,
|
||||
onSecondaryContainer = scheme.onSecondaryContainer,
|
||||
tertiary = scheme.tertiary,
|
||||
onTertiary = scheme.onTertiary,
|
||||
tertiaryContainer = scheme.tertiaryContainer,
|
||||
onTertiaryContainer = scheme.onTertiaryContainer,
|
||||
background = scheme.background,
|
||||
onBackground = scheme.onBackground,
|
||||
surface = scheme.surface,
|
||||
onSurface = scheme.onSurface,
|
||||
surfaceVariant = scheme.surfaceVariant,
|
||||
onSurfaceVariant = scheme.onSurfaceVariant,
|
||||
surfaceTint = scheme.surfaceTint,
|
||||
inverseSurface = scheme.inverseSurface,
|
||||
inverseOnSurface = scheme.inverseOnSurface,
|
||||
error = scheme.error,
|
||||
onError = scheme.onError,
|
||||
errorContainer = scheme.errorContainer,
|
||||
onErrorContainer = scheme.onErrorContainer,
|
||||
border = scheme.outline,
|
||||
borderVariant = scheme.outlineVariant,
|
||||
scrim = scheme.scrim
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLoginForm(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val serverFocusRequester = remember { FocusRequester() }
|
||||
val findFocusRequester = remember { FocusRequester() }
|
||||
val changeServerFocusRequester = remember { FocusRequester() }
|
||||
val quickConnectFocusRequester = remember { FocusRequester() }
|
||||
val usernameFocusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
val connectFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(state.phase) {
|
||||
if (state.phase == LoginContentPhase.ServerSearch) {
|
||||
serverFocusRequester.requestFocus()
|
||||
} else if (state.quickConnectAvailable) {
|
||||
quickConnectFocusRequester.requestFocus()
|
||||
} else {
|
||||
usernameFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(horizontal = 64.dp, vertical = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = if (state.phase == LoginContentPhase.Login) 1040.dp else 480.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PurefinLogo(
|
||||
color = scheme.primary,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Purefin",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = if (state.phase == LoginContentPhase.ServerSearch) {
|
||||
"Find your Jellyfin server"
|
||||
} else {
|
||||
"Connect to your media server"
|
||||
},
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
state.errorMessage?.let { errorMessage ->
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = scheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = scheme.errorContainer,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
when (state.phase) {
|
||||
LoginContentPhase.ServerSearch -> TvServerSearchFields(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
serverFocusRequester = serverFocusRequester,
|
||||
findFocusRequester = findFocusRequester
|
||||
)
|
||||
LoginContentPhase.Login -> TvLoginFields(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
changeServerFocusRequester = changeServerFocusRequester,
|
||||
quickConnectFocusRequester = quickConnectFocusRequester,
|
||||
usernameFocusRequester = usernameFocusRequester,
|
||||
passwordFocusRequester = passwordFocusRequester,
|
||||
connectFocusRequester = connectFocusRequester
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvServerSearchFields(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
serverFocusRequester: FocusRequester,
|
||||
findFocusRequester: FocusRequester
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TvLoginTextField(
|
||||
label = "Server URL",
|
||||
value = state.serverUrl,
|
||||
onValueChange = callbacks.onServerUrlChange,
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage,
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocusRequester)
|
||||
.focusProperties {
|
||||
down = findFocusRequester
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = callbacks.onFindServer,
|
||||
enabled = !state.isSearching,
|
||||
modifier = Modifier
|
||||
.focusRequester(findFocusRequester)
|
||||
.focusProperties {
|
||||
up = serverFocusRequester
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
TvText(
|
||||
text = if (state.isSearching) "Searching..." else "Find server",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (state.discoveredServers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = "Nearby servers",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
state.discoveredServers.take(3).forEach { server ->
|
||||
Button(
|
||||
onClick = { callbacks.onDiscoveredServerClick(server) },
|
||||
modifier = Modifier.fillMaxWidth().height(54.dp)
|
||||
) {
|
||||
TvText(
|
||||
text = server.name ?: server.address,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLoginFields(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
changeServerFocusRequester: FocusRequester,
|
||||
quickConnectFocusRequester: FocusRequester,
|
||||
usernameFocusRequester: FocusRequester,
|
||||
passwordFocusRequester: FocusRequester,
|
||||
connectFocusRequester: FocusRequester
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Selected server",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = selectedServer,
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = callbacks.onChangeServer,
|
||||
modifier = Modifier
|
||||
.focusRequester(changeServerFocusRequester)
|
||||
.focusProperties {
|
||||
down = if (state.quickConnectAvailable) quickConnectFocusRequester else usernameFocusRequester
|
||||
}
|
||||
.width(176.dp)
|
||||
.height(44.dp)
|
||||
) {
|
||||
TvText(text = "Change server", fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
TvLoginOptionPanel(
|
||||
title = "Quick Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (state.quickConnectAvailable) {
|
||||
val quickConnectCode = state.quickConnectCode
|
||||
if (quickConnectCode != null) {
|
||||
Text(
|
||||
text = quickConnectCode,
|
||||
color = scheme.primary,
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Approve this code in another Jellyfin client.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Text(
|
||||
text = "Use another Jellyfin client to approve this TV without entering your password.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Button(
|
||||
onClick = if (state.quickConnectCode == null) {
|
||||
callbacks.onQuickConnect
|
||||
} else {
|
||||
callbacks.onCancelQuickConnect
|
||||
},
|
||||
enabled = state.quickConnectCode != null || !state.isQuickConnecting,
|
||||
modifier = Modifier
|
||||
.focusRequester(quickConnectFocusRequester)
|
||||
.focusProperties {
|
||||
up = changeServerFocusRequester
|
||||
right = usernameFocusRequester
|
||||
down = usernameFocusRequester
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
TvText(
|
||||
text = if (state.quickConnectCode == null) {
|
||||
"Quick Connect"
|
||||
} else {
|
||||
"Cancel Quick Connect"
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Quick Connect is not enabled on this server.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TvLoginOptionPanel(
|
||||
title = "Manual login",
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
TvLoginTextField(
|
||||
label = "Username",
|
||||
value = state.username,
|
||||
onValueChange = callbacks.onUsernameChange,
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person,
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocusRequester)
|
||||
.focusProperties {
|
||||
up = changeServerFocusRequester
|
||||
left = if (state.quickConnectAvailable) {
|
||||
quickConnectFocusRequester
|
||||
} else {
|
||||
FocusRequester.Default
|
||||
}
|
||||
down = passwordFocusRequester
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
TvLoginPasswordField(
|
||||
label = "Password",
|
||||
value = state.password,
|
||||
onValueChange = callbacks.onPasswordChange,
|
||||
placeholder = "Enter your password",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
modifier = Modifier
|
||||
.focusRequester(passwordFocusRequester)
|
||||
.focusProperties {
|
||||
up = usernameFocusRequester
|
||||
down = connectFocusRequester
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Button(
|
||||
onClick = callbacks.onConnect,
|
||||
enabled = !state.isQuickConnecting,
|
||||
modifier = Modifier
|
||||
.focusRequester(connectFocusRequester)
|
||||
.focusProperties {
|
||||
up = passwordFocusRequester
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
TvText(
|
||||
text = "Connect",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLoginOptionPanel(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(scheme.surface)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = scheme.outlineVariant.copy(alpha = 0.45f),
|
||||
shape = shape
|
||||
)
|
||||
.padding(22.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLoginTextField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
leadingIcon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.4f),
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeholder,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
trailingIcon = trailingIcon,
|
||||
visualTransformation = visualTransformation,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = scheme.surfaceContainer,
|
||||
unfocusedContainerColor = scheme.surface,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
cursorColor = scheme.primary,
|
||||
focusedTextColor = scheme.onSurface,
|
||||
unfocusedTextColor = scheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLoginPasswordField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
leadingIcon: ImageVector,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isPasswordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
TvLoginTextField(
|
||||
label = label,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
modifier = modifier,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (isPasswordVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = if (isPasswordVisible) "Hide password" else "Show password",
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (isPasswordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package hu.bbara.purefin.ui.screen.login
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.data.JellyfinServerCandidate
|
||||
import hu.bbara.purefin.core.feature.login.LoginViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun TvLoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val serverUrl by viewModel.url.collectAsStateWithLifecycle()
|
||||
val phase by viewModel.phase.collectAsStateWithLifecycle()
|
||||
val selectedServer by viewModel.selectedServer.collectAsStateWithLifecycle()
|
||||
val discoveredServers by viewModel.discoveredServers.collectAsStateWithLifecycle()
|
||||
val username by viewModel.username.collectAsStateWithLifecycle()
|
||||
val password by viewModel.password.collectAsStateWithLifecycle()
|
||||
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
|
||||
val isLoggingIn by viewModel.isLoggingIn.collectAsStateWithLifecycle()
|
||||
val quickConnectAvailable by viewModel.quickConnectAvailable.collectAsStateWithLifecycle()
|
||||
val quickConnectCode by viewModel.quickConnectCode.collectAsStateWithLifecycle()
|
||||
val isQuickConnecting by viewModel.isQuickConnecting.collectAsStateWithLifecycle()
|
||||
val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val state = remember(
|
||||
phase,
|
||||
serverUrl,
|
||||
selectedServer,
|
||||
discoveredServers,
|
||||
username,
|
||||
password,
|
||||
isSearching,
|
||||
isLoggingIn,
|
||||
quickConnectAvailable,
|
||||
quickConnectCode,
|
||||
isQuickConnecting,
|
||||
errorMessage
|
||||
) {
|
||||
LoginContentState(
|
||||
phase = if (phase == LoginViewModel.Phase.LOGIN) {
|
||||
LoginContentPhase.Login
|
||||
} else {
|
||||
LoginContentPhase.ServerSearch
|
||||
},
|
||||
serverUrl = serverUrl,
|
||||
selectedServerName = selectedServer?.name,
|
||||
selectedServerUrl = selectedServer?.address,
|
||||
discoveredServers = discoveredServers.map {
|
||||
LoginServerCandidateUi(name = it.name, address = it.address)
|
||||
},
|
||||
username = username,
|
||||
password = password,
|
||||
isSearching = isSearching,
|
||||
isLoggingIn = isLoggingIn,
|
||||
quickConnectAvailable = quickConnectAvailable,
|
||||
quickConnectCode = quickConnectCode,
|
||||
isQuickConnecting = isQuickConnecting,
|
||||
errorMessage = errorMessage
|
||||
)
|
||||
}
|
||||
val callbacks = remember(viewModel, coroutineScope) {
|
||||
LoginContentCallbacks(
|
||||
onServerUrlChange = viewModel::setUrl,
|
||||
onFindServer = viewModel::findServer,
|
||||
onDiscoveredServerClick = {
|
||||
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
|
||||
},
|
||||
onChangeServer = viewModel::changeServer,
|
||||
onUsernameChange = {
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
onPasswordChange = {
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
onConnect = {
|
||||
coroutineScope.launch {
|
||||
viewModel.login()
|
||||
}
|
||||
},
|
||||
onQuickConnect = viewModel::startQuickConnect,
|
||||
onCancelQuickConnect = viewModel::cancelQuickConnect
|
||||
)
|
||||
}
|
||||
|
||||
TvLoginContent(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package hu.bbara.purefin.ui.screen.movie
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvMovieScreen(
|
||||
movie: MovieDto,
|
||||
viewModel: MovieScreenViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(movie) {
|
||||
viewModel.selectMovie(movie)
|
||||
}
|
||||
|
||||
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
|
||||
|
||||
if (movieItem.value != null) {
|
||||
TvMovieScreenContent(
|
||||
movie = movieItem.value!!,
|
||||
onPlay = viewModel::onPlay,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
PurefinWaitingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvMovieScreenContent(
|
||||
movie: Movie,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(movie.id) {
|
||||
withFrameNanos { }
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = movie.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
heightFraction = 1f
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
) {
|
||||
TvMovieHeroSection(
|
||||
movie = movie,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package hu.bbara.purefin.ui.screen.movie.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
|
||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||
|
||||
@Composable
|
||||
internal fun TvMovieHeroSection(
|
||||
movie: Movie,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = 24.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Overview",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = movie.synopsis,
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(0.4f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Playback",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "ENG • Subtitles ENG",
|
||||
color = mutedStrong,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(MoviePlayButtonTag),
|
||||
focusedScale = 1.08f,
|
||||
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
|
||||
focusBorderWidth = 3.dp,
|
||||
focusBorderColor = scheme.onBackground,
|
||||
overlayBorderWidth = 2.dp,
|
||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||
focusable = true,
|
||||
height = 40.dp,
|
||||
textSize = 13.sp,
|
||||
iconSize = 20.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package hu.bbara.purefin.ui.screen.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.model.SegmentType
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvNextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
|
||||
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
||||
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun TvPlayerScreen(
|
||||
mediaId: String,
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(mediaId) {
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
|
||||
// This is a hack for timed visibility.
|
||||
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
|
||||
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
val backgroundFocusRequester = remember { FocusRequester() }
|
||||
|
||||
// Main section focus requesters
|
||||
var rootFocusRequester by remember { mutableStateOf(backgroundFocusRequester) }
|
||||
var controlsFocusRequester by remember { mutableStateOf(FocusRequester() ) }
|
||||
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||
val nextEpisodeFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
fun showControls() {
|
||||
controlsVisible = true
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Needed because of composition scheduling
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
|
||||
fun expandPlaylist() {
|
||||
// TODO: focus management for playlist expansion
|
||||
isPlaylistExpanded = true
|
||||
}
|
||||
fun closePlaylist() {
|
||||
// TODO focus
|
||||
isPlaylistExpanded = false
|
||||
}
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
}
|
||||
}
|
||||
fun handleBack() {
|
||||
when {
|
||||
trackPanelType != null -> closeTrackPanel()
|
||||
isPlaylistExpanded -> closePlaylist()
|
||||
controlsVisible -> hideControls()
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val showSkipIntroButton = !controlsVisible
|
||||
&& uiState.activeSkippableSegmentEndMs != null
|
||||
&& uiState.activeSkippableSegmentType == SegmentType.INTRO
|
||||
&& !uiState.isEnded
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& ((uiState.durationMs - uiState.positionMs) <= 30_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
|
||||
&& !uiState.isEnded
|
||||
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
|
||||
rootFocusRequester = when {
|
||||
showSkipIntroButton -> {
|
||||
skipButtonFocusRequester
|
||||
}
|
||||
showNextEpisodeOverlay -> {
|
||||
nextEpisodeFocusRequester
|
||||
}
|
||||
else -> {
|
||||
backgroundFocusRequester
|
||||
}
|
||||
}
|
||||
if (!controlsVisible) {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
when (pendingFocus) {
|
||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
||||
}
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.onKeyEvent { event ->
|
||||
if (event.key == Key.Back) {
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
handleBack()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
val handled = handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
controlsVisible = controlsVisible,
|
||||
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||
onShowControls = ::showControls,
|
||||
onTogglePlayback = {
|
||||
resumeStopFeedbackCounter++
|
||||
viewModel.togglePlayPause()
|
||||
},
|
||||
onSeekRelative = {
|
||||
hiddenSeekCounter++
|
||||
viewModel.seekBy(it)
|
||||
},
|
||||
)
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
handled
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
player = viewModel.player
|
||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.player = viewModel.player
|
||||
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TvPlayerControlsOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
},
|
||||
onSeekRelative = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
},
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
onExpandPlaylist = ::expandPlaylist,
|
||||
onCollapsePlaylist = ::closePlaylist,
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id)
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
TvPlayerClock()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSkipIntroButton,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = { viewModel.skipActiveSegment() },
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 32.dp,
|
||||
bottom = 140.dp
|
||||
)
|
||||
) {
|
||||
uiState.nextEpisode?.let { nextEpisode ->
|
||||
TvNextEpisodeOverlay(
|
||||
nextEpisode = nextEpisode,
|
||||
onClick = { viewModel.next() },
|
||||
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!showSkipIntroButton && !controlsVisible) {
|
||||
ValueChangeTimedVisibility(
|
||||
value = hiddenSeekCounter,
|
||||
hideAfterMillis = 2500L,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||
) {
|
||||
HiddenTvSeekTimeline(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TvPlayerLoadingErrorEndCard(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = { viewModel.next() },
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
viewModel.resumePlayback()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
|
||||
ValueChangeTimedVisibility(
|
||||
value = resumeStopFeedbackCounter,
|
||||
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = trackPanelType != null,
|
||||
enter = slideInHorizontally { it },
|
||||
exit = slideOutHorizontally { it }
|
||||
) {
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
uiState = uiState,
|
||||
onSelect = { track ->
|
||||
viewModel.selectTrack(track)
|
||||
closeTrackPanel()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenTvSeekTimeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
TvPlayerTimeRow(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = positionMs,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp),
|
||||
isFocused = false,
|
||||
thumbRadius = 7.dp,
|
||||
focusedThumbRadius = 9.dp,
|
||||
focusedThumbHaloRadiusDelta = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerClock(modifier: Modifier = Modifier) {
|
||||
var currentTime by remember { mutableStateOf(LocalTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
currentTime = LocalTime.now()
|
||||
delay(1_000L)
|
||||
}
|
||||
}
|
||||
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
|
||||
Text(
|
||||
text = formatter.format(currentTime),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: KeyEvent,
|
||||
controlsVisible: Boolean,
|
||||
popupVisible: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onShowControls: () -> Unit,
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
|
||||
if (!controlsVisible) {
|
||||
return when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
onSeekRelative(-10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onSeekRelative(30_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
onShowControls()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
if (popupVisible) {
|
||||
// Do nothing because the focused component is not he root, but an overlay
|
||||
false
|
||||
} else {
|
||||
onTogglePlayback()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerStopFeedback(
|
||||
stopped: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!stopped) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.72f))
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Pause,
|
||||
contentDescription = "Play/Pause playback",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun TvIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
size: Int = 52,
|
||||
enabled: Boolean = true,
|
||||
label: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.1f else 1.0f,
|
||||
label = "scale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||
label = "border"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) {
|
||||
scheme.primaryContainer
|
||||
} else {
|
||||
scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "background"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.onPrimaryContainer else scheme.onSurface,
|
||||
label = "content"
|
||||
)
|
||||
val shape = RoundedCornerShape(50)
|
||||
val iconSize = (size - 24).coerceAtLeast(0).dp
|
||||
|
||||
val buttonModifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.focusProperties { canFocus = enabled }
|
||||
.semantics {
|
||||
if (!enabled) {
|
||||
disabled()
|
||||
}
|
||||
}
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(enabled = enabled) { onClick() }
|
||||
|
||||
if (label == null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = contentColor,
|
||||
modifier = buttonModifier
|
||||
.size(size.dp)
|
||||
.padding(12.dp)
|
||||
.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = buttonModifier
|
||||
.widthIn(min = 104.dp)
|
||||
.height(size.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
|
||||
/**
|
||||
* TV-optimized overlay button that appears near the end of a media item to let the user
|
||||
* quickly jump to the next episode. Shows the next episode's artwork and title.
|
||||
* Supports D-pad navigation and focus animations.
|
||||
*/
|
||||
@Composable
|
||||
fun TvNextEpisodeOverlay(
|
||||
nextEpisode: PlaylistElementUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.05f else 1f,
|
||||
label = "nextEpisodeScale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||
label = "nextEpisodeBorder"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) {
|
||||
scheme.surfaceContainerHigh.copy(alpha = 0.96f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.85f)
|
||||
},
|
||||
label = "nextEpisodeBackground"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(280.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onClick() },
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// "Up Next" label
|
||||
Text(
|
||||
text = "Up Next",
|
||||
color = scheme.secondary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Thumbnail with play button overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = nextEpisode.artworkUrl?.takeIf { it.isNotEmpty() },
|
||||
contentDescription = nextEpisode.title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// Dark gradient overlay for readability
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.1f),
|
||||
Color.Black.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Episode title
|
||||
Text(
|
||||
text = nextEpisode.title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val TvPlayerNextEpisodeOverlayTag = "tv_player_next_episode_overlay"
|
||||
@@ -0,0 +1,372 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
|
||||
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
|
||||
internal const val TvPlayerSeekBarTag = "tv_player_seek_bar"
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
qualityButtonFocusRequester: FocusRequester,
|
||||
audioButtonFocusRequester: FocusRequester,
|
||||
subtitlesButtonFocusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
qualityButtonEnabled: Boolean,
|
||||
audioButtonEnabled: Boolean,
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
TvPlayerTopBar(
|
||||
title = uiState.title ?: "Playing",
|
||||
subtitle = uiState.subtitle,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
TvPlayerBottomSection(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
onOpenAudioPanel = onOpenAudioPanel,
|
||||
onOpenSubtitlesPanel = onOpenSubtitlesPanel,
|
||||
onOpenQualityPanel = onOpenQualityPanel,
|
||||
onExpandPlaylist = onExpandPlaylist,
|
||||
onCollapsePlaylist = onCollapsePlaylist,
|
||||
onSelectQueueItem = onSelectQueueItem,
|
||||
qualityButtonEnabled = qualityButtonEnabled,
|
||||
audioButtonEnabled = audioButtonEnabled,
|
||||
subtitlesButtonEnabled = subtitlesButtonEnabled,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerTopBar(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = scheme.onBackground.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerBottomSection(
|
||||
uiState: PlayerUiState,
|
||||
focusRequester: FocusRequester,
|
||||
isPlaylistExpanded: Boolean,
|
||||
qualityButtonFocusRequester: FocusRequester,
|
||||
audioButtonFocusRequester: FocusRequester,
|
||||
subtitlesButtonFocusRequester: FocusRequester,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onOpenAudioPanel: () -> Unit,
|
||||
onOpenSubtitlesPanel: () -> Unit,
|
||||
onOpenQualityPanel: () -> Unit,
|
||||
onExpandPlaylist: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onSelectQueueItem: (String) -> Unit,
|
||||
qualityButtonEnabled: Boolean,
|
||||
audioButtonEnabled: Boolean,
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val playlistFocusRequester = remember { FocusRequester() }
|
||||
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
||||
|
||||
LaunchedEffect(isPlaylistExpanded) {
|
||||
if (isPlaylistExpanded && uiState.queue.isNotEmpty()) {
|
||||
playlistFocusRequester.requestFocus()
|
||||
} else if (wasPlaylistExpanded) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
wasPlaylistExpanded = isPlaylistExpanded
|
||||
}
|
||||
|
||||
val playlistExpandState = if (isPlaylistExpanded) "expanded" else "collapsed"
|
||||
val expandPlaylistModifier = Modifier.onPreviewKeyEvent { event ->
|
||||
handleExpandPlaylistKey(event, onExpandPlaylist)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.testTag(TvPlayerPlaylistStateTag)
|
||||
.semantics { stateDescription = playlistExpandState }
|
||||
) {
|
||||
TvPlayerTimeRow(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
TvPlayerSeekBar(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
togglePlayState = onPlayPause,
|
||||
onMoveDown = {
|
||||
focusRequester.requestFocus()
|
||||
true
|
||||
},
|
||||
modifier = Modifier.testTag(TvPlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
onClick = onPrevious,
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
onClick = onPlayPause,
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(TvPlayerPlayPauseButtonTag)
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
onClick = onNext,
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvQualitySelectionButton(
|
||||
onClick = onOpenQualityPanel,
|
||||
enabled = qualityButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(qualityButtonFocusRequester)
|
||||
.testTag(TvPlayerQualityButtonTag)
|
||||
)
|
||||
TvAudioSelectionButton(
|
||||
onClick = onOpenAudioPanel,
|
||||
enabled = audioButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(audioButtonFocusRequester)
|
||||
.testTag(TvPlayerAudioButtonTag)
|
||||
)
|
||||
TvSubtitlesSelectionButton(
|
||||
onClick = onOpenSubtitlesPanel,
|
||||
enabled = subtitlesButtonEnabled,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(subtitlesButtonFocusRequester)
|
||||
.testTag(TvPlayerSubtitlesButtonTag)
|
||||
)
|
||||
}
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = onSkipSegment,
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isPlaylistExpanded,
|
||||
enter = fadeIn() + expandVertically(expandFrom = Alignment.Top),
|
||||
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)
|
||||
) {
|
||||
TvPlayerQueuePanel(
|
||||
uiState = uiState,
|
||||
firstItemFocusRequester = playlistFocusRequester,
|
||||
onSelect = onSelectQueueItem,
|
||||
onReturnToControls = onCollapsePlaylist,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(if (isPlaylistExpanded) 12.dp else 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerTimeRow(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(positionMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = formatTime(durationMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExpandPlaylistKey(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
onExpand: () -> Unit
|
||||
): Boolean {
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) {
|
||||
onExpand()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun formatTime(positionMs: Long): String {
|
||||
val totalSeconds = positionMs / 1000
|
||||
val seconds = (totalSeconds % 60).toInt()
|
||||
val minutes = ((totalSeconds / 60) % 60).toInt()
|
||||
val hours = (totalSeconds / 3600).toInt()
|
||||
return if (hours > 0) {
|
||||
"%d:%02d:%02d".format(hours, minutes, seconds)
|
||||
} else {
|
||||
"%02d:%02d".format(minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerLoadingErrorEndCard(
|
||||
uiState: PlayerUiState,
|
||||
onRetry: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onReplay: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PlayerLoadingErrorEndCardContent(
|
||||
uiState = uiState,
|
||||
onRetry = onRetry,
|
||||
onNext = onNext,
|
||||
onReplay = onReplay,
|
||||
onDismissError = onDismissError,
|
||||
modifier = modifier,
|
||||
showBufferWhileError = false,
|
||||
cardPadding = 24.dp,
|
||||
cardSpacing = 16.dp,
|
||||
backgroundAlpha = 0.92f,
|
||||
headlineStyle = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
|
||||
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
|
||||
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
|
||||
internal const val TvPlayerPlaylistFirstItemTag = "tv_player_playlist_first_item"
|
||||
internal const val TvPlayerPlaylistLastItemTag = "tv_player_playlist_last_item"
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerQueuePanel(
|
||||
uiState: PlayerUiState,
|
||||
firstItemFocusRequester: FocusRequester,
|
||||
onSelect: (String) -> Unit,
|
||||
onReturnToControls: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val currentIndex = uiState.queue.indexOfFirst { it.isCurrent }
|
||||
val entryIndex = if (currentIndex >= 0) currentIndex else 0
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
|
||||
)
|
||||
val queueCountLabel = when (uiState.queue.size) {
|
||||
0 -> "No items in queue"
|
||||
1 -> "1 item in queue"
|
||||
else -> "${uiState.queue.size} items in queue"
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.queue, entryIndex) {
|
||||
if (uiState.queue.isNotEmpty()) {
|
||||
withFrameNanos { }
|
||||
firstItemFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surface.copy(alpha = 0.94f)
|
||||
) {
|
||||
if (uiState.queue.isEmpty()) {
|
||||
Text(
|
||||
text = "Add something to the queue to browse it here.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TvPlayerPlaylistRowTag),
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
||||
val isEntryItem = index == entryIndex
|
||||
TvQueueRowCard(
|
||||
item = item,
|
||||
isCurrent = item.isCurrent,
|
||||
isFirst = index == 0,
|
||||
isLast = index == uiState.queue.lastIndex,
|
||||
onClick = { onSelect(item.id) },
|
||||
onReturnToControls = onReturnToControls,
|
||||
modifier = Modifier
|
||||
.width(228.dp)
|
||||
.then(
|
||||
if (isEntryItem) {
|
||||
Modifier
|
||||
.focusRequester(firstItemFocusRequester)
|
||||
.testTag(TvPlayerPlaylistCurrentItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvQueueRowCard(
|
||||
item: PlaylistElementUiModel,
|
||||
isCurrent: Boolean,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onReturnToControls: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.06f else 1f,
|
||||
label = "queueCardScale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primary
|
||||
isCurrent -> scheme.tertiary
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
label = "queueCardBorder"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primaryContainer.copy(alpha = 0.96f)
|
||||
isCurrent -> scheme.tertiaryContainer.copy(alpha = 0.86f)
|
||||
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "queueCardBackground"
|
||||
)
|
||||
val contentColor = when {
|
||||
isFocused -> scheme.onPrimaryContainer
|
||||
isCurrent -> scheme.onTertiaryContainer
|
||||
else -> scheme.onSurface
|
||||
}
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.border(
|
||||
width = when {
|
||||
isFocused -> 3.dp
|
||||
isCurrent -> 2.dp
|
||||
else -> 1.dp
|
||||
},
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
false
|
||||
} else {
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
onReturnToControls()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionLeft -> isFirst
|
||||
Key.DirectionRight -> isLast
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
.clickable { onClick() }
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
TvQueueArtwork(
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = isCurrent,
|
||||
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
|
||||
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = item.title,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvQueueArtwork(
|
||||
artworkUrl: String?,
|
||||
isCurrent: Boolean,
|
||||
badgeColor: Color,
|
||||
badgeContentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val placeholderPainter = ColorPainter(scheme.surfaceVariant)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(scheme.surfaceContainerHigh)
|
||||
) {
|
||||
if (artworkUrl != null) {
|
||||
AsyncImage(
|
||||
model = artworkUrl.takeIf { it.isNotEmpty() },
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = placeholderPainter,
|
||||
error = placeholderPainter,
|
||||
fallback = placeholderPainter
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.background(scheme.surfaceContainerHigh)
|
||||
)
|
||||
}
|
||||
if (isCurrent) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(badgeColor)
|
||||
.padding(horizontal = 9.dp, vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = badgeContentColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Now playing",
|
||||
color = badgeContentColor,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerSeekBar(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
togglePlayState: () -> Unit,
|
||||
onMoveDown: (() -> Boolean)? = null,
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
val position = positionMs.coerceIn(0, safeDuration)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.height(32.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
when (event.key) {
|
||||
Key.DirectionDown -> onMoveDown?.invoke() ?: false
|
||||
|
||||
Key.DirectionLeft -> {
|
||||
onSeekRelative(-10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onSeekRelative(10_000)
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionCenter,
|
||||
Key.Enter,
|
||||
Key.NumPadEnter -> {
|
||||
togglePlayState()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.focusable(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = position,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isFocused = isFocused,
|
||||
thumbRadius = 7.dp,
|
||||
focusedThumbRadius = 9.dp,
|
||||
focusedThumbHaloRadiusDelta = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.ClosedCaption
|
||||
import androidx.compose.material.icons.outlined.HighQuality
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
|
||||
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
|
||||
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
|
||||
internal const val TvPlayerSubtitlesButtonTag = "tv_player_subtitles_button"
|
||||
internal const val TvPlayerTrackPanelTag = "tv_player_track_panel"
|
||||
internal const val TvPlayerTrackSelectedItemTag = "tv_player_track_selected_item"
|
||||
internal const val TvPlayerTrackFirstItemTag = "tv_player_track_first_item"
|
||||
internal const val TvPlayerTrackLastItemTag = "tv_player_track_last_item"
|
||||
|
||||
internal enum class TvTrackPanelType {
|
||||
AUDIO,
|
||||
SUBTITLES,
|
||||
QUALITY
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvQualitySelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.HighQuality,
|
||||
contentDescription = "Quality",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvAudioSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Language,
|
||||
contentDescription = "Audio",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvSubtitlesSelectionButton(
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.ClosedCaption,
|
||||
contentDescription = "Subtitles",
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvTrackSelectionPanel(
|
||||
panelType: TvTrackPanelType,
|
||||
uiState: PlayerUiState,
|
||||
onSelect: (TrackOption) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (title, options, selectedId) = when (panelType) {
|
||||
TvTrackPanelType.AUDIO -> Triple("Audio", uiState.audioTracks, uiState.selectedAudioTrackId)
|
||||
TvTrackPanelType.SUBTITLES -> Triple("Subtitles", uiState.textTracks, uiState.selectedTextTrackId)
|
||||
TvTrackPanelType.QUALITY -> Triple("Quality", uiState.qualityTracks, uiState.selectedQualityTrackId)
|
||||
}
|
||||
val entryIndex = options.indexOfFirst { it.id == selectedId }.takeIf { it >= 0 } ?: 0
|
||||
val focusRequesters = remember(options.map(TrackOption::id)) {
|
||||
options.map { FocusRequester() }
|
||||
}
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = (entryIndex - 1).coerceAtLeast(0)
|
||||
)
|
||||
|
||||
LaunchedEffect(entryIndex, focusRequesters) {
|
||||
withFrameNanos { }
|
||||
focusRequesters.getOrNull(entryIndex)?.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TvPlayerTrackPanelTag),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(320.dp)
|
||||
.clip(RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)),
|
||||
color = scheme.surface.copy(alpha = 0.97f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 500.dp)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
itemsIndexed(options, key = { _, option -> option.id }) { index, option ->
|
||||
val entryItemTag = when {
|
||||
selectedId == null && index == entryIndex -> TvPlayerTrackFirstItemTag
|
||||
index == entryIndex -> TvPlayerTrackSelectedItemTag
|
||||
index == 0 -> TvPlayerTrackFirstItemTag
|
||||
index == options.lastIndex -> TvPlayerTrackLastItemTag
|
||||
else -> null
|
||||
}
|
||||
TvTrackOptionRow(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == entryIndex) {
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (entryItemTag != null) {
|
||||
Modifier.testTag(entryItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
label = option.label,
|
||||
selected = option.id == selectedId,
|
||||
isFirst = index == 0,
|
||||
isLast = index == options.lastIndex,
|
||||
onClick = { onSelect(option) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvTrackOptionRow(
|
||||
modifier: Modifier = Modifier,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
isFirst: Boolean,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primaryContainer
|
||||
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
|
||||
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "trackOptionBackground"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.onPrimaryContainer
|
||||
selected -> scheme.onSecondaryContainer
|
||||
else -> scheme.onSurface
|
||||
},
|
||||
label = "trackOptionContent"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primary
|
||||
selected -> scheme.secondary
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.45f)
|
||||
},
|
||||
label = "trackOptionBorder"
|
||||
)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
false
|
||||
} else {
|
||||
when (event.key) {
|
||||
Key.DirectionLeft, Key.DirectionRight -> true
|
||||
Key.DirectionUp -> isFirst
|
||||
Key.DirectionDown -> isLast
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package hu.bbara.purefin.ui.screen.series
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import java.util.UUID
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
fun TvSeriesScreen(
|
||||
series: SeriesDto,
|
||||
focusedSeasonId: UUID? = null,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(series) {
|
||||
viewModel.selectSeries(series)
|
||||
}
|
||||
|
||||
val series = viewModel.series.collectAsStateWithLifecycle()
|
||||
|
||||
val seriesData = series.value
|
||||
if (seriesData != null) {
|
||||
TvSeriesScreenContent(
|
||||
series = seriesData,
|
||||
selectSeason = viewModel::selectSeason,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
focusedSeasonId = focusedSeasonId,
|
||||
focusedEpisodeId = focusedEpisodeId,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
PurefinWaitingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvSeriesScreenContent(
|
||||
series: Series,
|
||||
selectSeason: (UUID, UUID) -> Unit,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
focusedSeasonId: UUID? = null,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val defaultSeason = series.defaultSeason(focusedSeasonId)
|
||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||
mutableStateOf(defaultSeason?.id)
|
||||
}
|
||||
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val initialFocusSeasonId = defaultSeason?.id
|
||||
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
||||
series.allSeasons.firstOrNull { it.id == seasonId }
|
||||
} ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
|
||||
initialFocusSeason?.episodes?.isEmpty() == true &&
|
||||
initialFocusSeason.episodeCount > 0
|
||||
|
||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = series.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
heightFraction = 1f
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
) {
|
||||
TvSeriesHeroSection(
|
||||
series = series,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (selectedSeason != null) {
|
||||
TvSeasonTabs(
|
||||
seasons = series.allSeasons,
|
||||
selectedSeason = selectedSeason,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
requestSelectedItemFocus = initialFocusedEpisodeId == null &&
|
||||
!waitingForInitialEpisodes &&
|
||||
!requestedInitialSeasonFocus,
|
||||
onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
focusedEpisodeId = initialFocusedEpisodeId,
|
||||
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
|
||||
onFocusRequested = { requestedInitialEpisodeFocus = true },
|
||||
upFocusRequester = seasonTabFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
TvText(
|
||||
text = "Loading seasons...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||
if (focusedSeasonId != null) {
|
||||
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
}
|
||||
|
||||
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Season.nextUpEpisode(): Episode? {
|
||||
return episodes.firstOrNull { !it.watched } ?: episodes.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Season.focusTargetEpisodeId(focusedEpisodeId: UUID?): UUID? {
|
||||
val focusedEpisode = episodes.firstOrNull { it.id == focusedEpisodeId }
|
||||
return focusedEpisode?.id ?: nextUpEpisode()?.id
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package hu.bbara.purefin.ui.screen.series.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeRowBringIntoViewSpec
|
||||
import java.util.UUID
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
|
||||
|
||||
@Composable
|
||||
internal fun TvSeriesMetaChips(series: Series) {
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(series.year, "${series.seasonCount} Seasons")
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun TvSeasonTabs(
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
requestSelectedItemFocus: Boolean = false,
|
||||
onSelectedItemFocusRequested: () -> Unit = {},
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(selectedSeasonIndex, requestSelectedItemFocus, seasons.size) {
|
||||
if (seasons.isEmpty()) return@LaunchedEffect
|
||||
listState.scrollToItem(selectedSeasonIndex)
|
||||
if (requestSelectedItemFocus && selectedItemFocusRequester != null) {
|
||||
withFrameNanos { }
|
||||
selectedItemFocusRequester.requestFocus()
|
||||
onSelectedItemFocusRequested()
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (selectedItemFocusRequester != null) {
|
||||
Modifier.focusRestorer(selectedItemFocusRequester)
|
||||
} else {
|
||||
Modifier.focusRestorer()
|
||||
}
|
||||
)
|
||||
.focusGroup()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
|
||||
TvSeasonTab(
|
||||
name = season.name,
|
||||
isSelected = index == selectedSeasonIndex,
|
||||
onFocused = { onSelect(season) },
|
||||
onClick = { onSelect(season) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
|
||||
Modifier.focusRequester(selectedItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSeasonTab(
|
||||
name: String,
|
||||
isSelected: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val color = if (isSelected) scheme.primary else scheme.onSurface
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) {
|
||||
onFocused()
|
||||
isFocused.value = true
|
||||
} else {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
TvText(
|
||||
text = name,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun TvEpisodeCarousel(
|
||||
episodes: List<Episode>,
|
||||
onPlayEpisode: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
requestFocus: Boolean = false,
|
||||
onFocusRequested: () -> Unit = {},
|
||||
upFocusRequester: FocusRequester? = null
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val rowScopedFocusRequester = remember { FocusRequester() }
|
||||
var rowFocusedEpisodeId by remember { mutableStateOf<UUID?>(null) }
|
||||
var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) }
|
||||
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||
episodes.indexOfFirst { it.id == id }
|
||||
} ?: -1
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }
|
||||
val targetEpisodeIndex = when {
|
||||
focusedEpisodeIndex >= 0 -> focusedEpisodeIndex
|
||||
firstUnwatchedIndex >= 0 -> firstUnwatchedIndex
|
||||
episodes.isNotEmpty() -> 0
|
||||
else -> -1
|
||||
}
|
||||
val targetEpisodeId = episodes.getOrNull(targetEpisodeIndex)?.id
|
||||
|
||||
LaunchedEffect(episodes, targetEpisodeId) {
|
||||
if (episodes.none { it.id == rowFocusedEpisodeId }) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
}
|
||||
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(requestFocus, targetEpisodeId) {
|
||||
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
withFrameNanos { }
|
||||
rowScopedFocusRequester.requestFocus()
|
||||
requestedFocusEpisodeId = targetEpisodeId
|
||||
onFocusRequested()
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.focusRestorer(rowScopedFocusRequester)
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||
TvEpisodeCard(
|
||||
episode = episode,
|
||||
onPlayEpisode = { onPlayEpisode(episode) },
|
||||
onFocused = { rowFocusedEpisodeId = episode.id },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (episode.id == rowFocusedEpisodeId) {
|
||||
Modifier.focusRequester(rowScopedFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(upFocusRequester?.let { requester ->
|
||||
Modifier.focusProperties { up = requester }
|
||||
} ?: Modifier)
|
||||
.then(
|
||||
if (episode.id == targetEpisodeId) {
|
||||
Modifier.testTag(SeriesNextUpEpisodeCardTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvSeriesHeroSection(
|
||||
series: Series,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = 24.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TvSeriesMetaChips(series = series)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Overview",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = series.synopsis,
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvEpisodeCard(
|
||||
episode: Episode,
|
||||
onPlayEpisode: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(260.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.clickable { onPlayEpisode() },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(scheme.surface)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(scheme.background.copy(alpha = 0.2f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PlayCircle,
|
||||
contentDescription = null,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.background(scheme.background.copy(alpha = 0.8f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = episode.runtime,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
if (episode.watched.not() && (episode.progress ?: 0.0) > 0) {
|
||||
MediaProgressBar(
|
||||
progress = (episode.progress ?: 0.0).toFloat().div(100),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
)
|
||||
} else {
|
||||
WatchStateBadge(
|
||||
watched = episode.watched,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
) {
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
// MediaCastRow(
|
||||
// cast = cast,
|
||||
// modifier = modifier,
|
||||
// cardWidth = 84.dp,
|
||||
// nameSize = 11.sp,
|
||||
// roleSize = 10.sp
|
||||
// )
|
||||
}
|
||||
|
||||
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
|
||||
if (isFocused) {
|
||||
this.border(
|
||||
width = 2.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.feature.settings.SettingsViewModel
|
||||
import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.ReadOnlySetting
|
||||
import hu.bbara.purefin.core.settings.SettingOption
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TvSettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit = viewModel::onBack,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(viewModel, context) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
TvSettingsTopBar(onBack = onBack)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
settingGroups.forEach { group ->
|
||||
group.title?.let { title ->
|
||||
item {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
group.options.forEach { option ->
|
||||
item(key = option.key) {
|
||||
TvSettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSettingOptionItem(
|
||||
option: SettingOption<*>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
when (option) {
|
||||
is RangeSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvNumberSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BooleanSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvBooleanSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is StringSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvStringSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReadOnlySetting -> {
|
||||
TvReadOnlySettingItem(
|
||||
title = option.title,
|
||||
value = option.value
|
||||
)
|
||||
}
|
||||
|
||||
is VoidSetting -> {
|
||||
TvVoidSettingItem(
|
||||
title = option.title,
|
||||
onClick = { viewModel.onClick(option) }
|
||||
)
|
||||
}
|
||||
|
||||
is DropdownSetting<*> -> {
|
||||
TvDropdownSettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> TvDropdownSettingOptionItem(
|
||||
option: DropdownSetting<T>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvDropdownSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
options = option.options,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvReadOnlySettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvVoidSettingItem(
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> TvDropdownSettingItem(
|
||||
title: String,
|
||||
value: T,
|
||||
options: List<T>,
|
||||
onValueChange: (T) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true }
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.toString()) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onValueChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvNumberSettingItem(
|
||||
title: String,
|
||||
value: Double,
|
||||
valueRange: ClosedFloatingPointRange<Double>,
|
||||
onValueChange: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.US, "%.1f", sliderValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
|
||||
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvBooleanSettingItem(
|
||||
title: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onValueChange(!value) }
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onValueChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvStringSettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/purefin_logo_background"/>
|
||||
<background android:drawable="@color/purefin_logo_background"/>
|
||||
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/purefin_logo_background"/>
|
||||
<background android:drawable="@color/purefin_logo_background"/>
|
||||
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
4
app-tv/src/main/res/values/purefin_logo_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purefin_logo_background">#201337</color>
|
||||
</resources>
|
||||
@@ -1,37 +0,0 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EpisodeTopBarShortcutTest {
|
||||
|
||||
@Test
|
||||
fun `home route exposes series shortcut`() {
|
||||
var clicked = false
|
||||
|
||||
val shortcut = episodeTopBarShortcut(Route.Home) {
|
||||
clicked = true
|
||||
}
|
||||
|
||||
assertNotNull(shortcut)
|
||||
assertEquals("Series", shortcut?.label)
|
||||
|
||||
shortcut?.onClick?.invoke()
|
||||
|
||||
assertTrue(clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non home route hides series shortcut`() {
|
||||
val shortcut = episodeTopBarShortcut(
|
||||
previousRoute = Route.PlayerRoute(mediaId = "episode-1"),
|
||||
onSeriesClick = {}
|
||||
)
|
||||
|
||||
assertNull(shortcut)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,34 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.MediaKind
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class TvFocusedItemHeroTest {
|
||||
|
||||
@Test
|
||||
fun movieItem_mapsProgressMetadataAndBackdrop() {
|
||||
fun movieItem_mapsMetadataAndBackdrop() {
|
||||
val movie = sampleMovie(progress = 42.0)
|
||||
val item = ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
type = MediaKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
|
||||
val hero = item.toTvFocusedHeroModel()
|
||||
|
||||
assertEquals("Continue watching", hero.eyebrowText)
|
||||
assertEquals("Blade Runner 2049", hero.title)
|
||||
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
|
||||
assertEquals(0.42f, hero.progressFraction!!, 0.0001f)
|
||||
assertEquals("42%", hero.progressLabel)
|
||||
assertNull(hero.watchedText)
|
||||
assertEquals(
|
||||
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP),
|
||||
ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.BACKDROP),
|
||||
hero.backdropImageUrl
|
||||
)
|
||||
}
|
||||
@@ -50,7 +45,7 @@ class TvFocusedItemHeroTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeItem_prefersWatchedStateOverProgress() {
|
||||
fun episodeItem_mapsEpisodeMetadata() {
|
||||
val item = NextUpItem(
|
||||
episode = sampleEpisode(
|
||||
progress = 96.0,
|
||||
@@ -60,29 +55,21 @@ class TvFocusedItemHeroTest {
|
||||
|
||||
val hero = item.toTvFocusedHeroModel()
|
||||
|
||||
assertEquals("Next up", hero.eyebrowText)
|
||||
assertEquals("The Very Pulse of the Machine", hero.title)
|
||||
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
|
||||
assertEquals("Watched", hero.watchedText)
|
||||
assertNull(hero.progressFraction)
|
||||
assertNull(hero.progressLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() {
|
||||
fun seriesItem_usesSeasonAndUnwatchedMetadata() {
|
||||
val item = PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
type = MediaKind.SERIES,
|
||||
series = sampleSeries()
|
||||
)
|
||||
|
||||
val hero = item.toTvFocusedHeroModel()
|
||||
|
||||
assertEquals("Series", hero.eyebrowText)
|
||||
assertEquals("Love, Death & Robots", hero.title)
|
||||
assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText)
|
||||
assertNull(hero.watchedText)
|
||||
assertNull(hero.progressFraction)
|
||||
assertNull(hero.progressLabel)
|
||||
}
|
||||
|
||||
private fun sampleMovie(progress: Double? = 42.0): Movie {
|
||||
@@ -108,7 +95,9 @@ class TvFocusedItemHeroTest {
|
||||
return Episode(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||
seriesName = "Love, Death & Robots",
|
||||
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||
seasonIndex = 3,
|
||||
index = 3,
|
||||
title = "The Very Pulse of the Machine",
|
||||
synopsis = "An astronaut faces a strange world alone.",
|
||||
@@ -0,0 +1,49 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class TvHomeBringIntoViewSpecTest {
|
||||
|
||||
private val topOffsetPx = 56f
|
||||
private val spec = tvHomeColumnBringIntoViewSpec(topOffsetPx)
|
||||
|
||||
@Test
|
||||
fun returnsZero_whenFocusedItemAlreadyAtTopOffset() {
|
||||
assertEquals(
|
||||
0f,
|
||||
spec.calculateScrollDistance(
|
||||
offset = topOffsetPx,
|
||||
size = 52f,
|
||||
containerSize = 240f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollsDownToTopOffset_whenFocusedItemIsBelowTarget() {
|
||||
assertEquals(
|
||||
64f,
|
||||
spec.calculateScrollDistance(
|
||||
offset = 120f,
|
||||
size = 52f,
|
||||
containerSize = 240f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrollsUpToTopOffset_whenFocusedItemIsAboveTarget() {
|
||||
assertEquals(
|
||||
-32f,
|
||||
spec.calculateScrollDistance(
|
||||
offset = 24f,
|
||||
size = 52f,
|
||||
containerSize = 240f
|
||||
),
|
||||
0.0001f
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.MediaKind
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -104,19 +104,20 @@ class TvHomeHeroStateTest {
|
||||
assertEquals(continueWatching, registry.firstAvailableItem)
|
||||
}
|
||||
|
||||
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem {
|
||||
return LibraryItem(
|
||||
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryUiModel {
|
||||
return LibraryUiModel(
|
||||
id = id,
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem {
|
||||
return ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
type = MediaKind.MOVIE,
|
||||
movie = sampleMovie(id = id, title = title)
|
||||
)
|
||||
}
|
||||
@@ -126,7 +127,9 @@ class TvHomeHeroStateTest {
|
||||
episode = Episode(
|
||||
id = UUID.fromString(id),
|
||||
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
seriesName = "Sample Series",
|
||||
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = "Next Episode",
|
||||
synopsis = "Episode synopsis",
|
||||
@@ -144,7 +147,7 @@ class TvHomeHeroStateTest {
|
||||
|
||||
private fun posterItem(id: String, libraryId: UUID): PosterItem {
|
||||
return PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
type = MediaKind.MOVIE,
|
||||
movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId)
|
||||
)
|
||||
}
|
||||