Compare commits
353 Commits
a0af187e8f
...
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 |
2
.gitignore
vendored
@@ -18,3 +18,5 @@
|
|||||||
local.properties
|
local.properties
|
||||||
.codex/
|
.codex/
|
||||||
openspec
|
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 devices: Select your device/emulator and click Run
|
||||||
- For Android TV: Select an Android TV emulator or device
|
- For Android TV: Select an Android TV emulator or device
|
||||||
|
|
||||||
### Download APK
|
### APK Repository
|
||||||
|
|
||||||
_Release builds coming soon_
|
APKs are distributed via the [APK Version Manager](https://apks.t.bbara.hu/docs) at `https://apks.t.bbara.hu`.
|
||||||
|
|
||||||
|
#### App names
|
||||||
|
|
||||||
|
| Variant | App name | Update URL |
|
||||||
|
|---------|----------|--------------------------------------------------------------|
|
||||||
|
| Release (phone) | `purefin-app` | `https://apks.t.bbara.hu/apps/purefin-app/update.json` |
|
||||||
|
| Debug (phone) | `purefin-app-debug` | `https://apks.t.bbara.hu/apps/purefin-app-debug/update.json` |
|
||||||
|
| Release (TV) | `purefin-tv` | `https://apks.t.bbara.hu/apps/purefin-tv/update.json` |
|
||||||
|
|
||||||
|
#### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Phone
|
||||||
|
./gradlew :app:assembleDebug # debug APK
|
||||||
|
./gradlew :app:assembleRelease # release APK (unsigned)
|
||||||
|
|
||||||
|
# Android TV
|
||||||
|
./gradlew :app-tv:assembleDebug # debug APK
|
||||||
|
./gradlew :app-tv:assembleRelease # release APK (unsigned)
|
||||||
|
```
|
||||||
|
|
||||||
|
APK outputs go to `app/build/outputs/apk/<variant>/` and `app-tv/build/outputs/apk/<variant>/`.
|
||||||
|
|
||||||
|
#### Uploading
|
||||||
|
|
||||||
|
Upload via the API at `POST /api/apps/{app_name}/upload`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://apks.t.bbara.hu/api/apps/purefin-app-debug/upload" \
|
||||||
|
-F "apk_file=@app/build/outputs/apk/debug/app-debug.apk" \
|
||||||
|
-F "version_code=$(grep purefinDebugVersionCode gradle.properties | cut -d= -f2)" \
|
||||||
|
-F "version_name=0.1-debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `version_code` is defined in `gradle.properties` (`purefinReleaseVersionCode` for phone release, `purefinDebugVersionCode` for phone debug, `purefinTvVersionCode` for TV). The `version_name` is `0.1` for release or `0.1-debug` for debug. Both fields are required — the server cannot auto-extract them from the APK.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.google.gms.google.services)
|
||||||
|
alias(libs.plugins.google.firebase.crashlytics)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin.tv"
|
namespace = "hu.bbara.purefin.tv"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "hu.bbara.purefin.tv"
|
applicationId = "hu.bbara.purefin.tv"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2000001
|
versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
|
||||||
versionName = "0.1"
|
versionName = "0.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
@@ -51,12 +53,15 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:model"))
|
implementation(project(":data"))
|
||||||
implementation(project(":core:data"))
|
implementation(project(":core"))
|
||||||
implementation(project(":core:player"))
|
implementation(project(":core-model"))
|
||||||
implementation(project(":feature:shared"))
|
implementation(project(":core-ui"))
|
||||||
|
implementation(libs.androidx.compose.animation)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -83,8 +88,7 @@ dependencies {
|
|||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.navigation3.ui)
|
implementation(libs.androidx.navigation3.ui)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.firebase.crashlytics)
|
||||||
ksp(libs.androidx.room.compiler)
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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,906 +0,0 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
|
||||||
import androidx.compose.ui.semantics.SemanticsProperties
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
|
||||||
import androidx.compose.ui.test.SemanticsMatcher
|
|
||||||
import androidx.compose.ui.test.assert
|
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
|
||||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
|
||||||
import hu.bbara.purefin.core.player.model.TrackOption
|
|
||||||
import hu.bbara.purefin.core.player.model.TrackType
|
|
||||||
import hu.bbara.purefin.tv.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
|
||||||
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedback
|
|
||||||
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedbackTag
|
|
||||||
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent
|
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
private const val TvPlayerRootTag = "tv_player_root"
|
|
||||||
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
|
||||||
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
class TvPlayerControlsOverlayTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
requestFocus = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.Focused,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"collapsed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"expanded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"collapsed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"expanded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initiallyExpanded = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Current").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState().copy(
|
|
||||||
queue = listOf(
|
|
||||||
QueueItemUi(
|
|
||||||
id = "first",
|
|
||||||
title = "Episode 1",
|
|
||||||
subtitle = "Fallback entry",
|
|
||||||
artworkUrl = null,
|
|
||||||
isCurrent = false
|
|
||||||
),
|
|
||||||
QueueItemUi(
|
|
||||||
id = "second",
|
|
||||||
title = "Episode 2",
|
|
||||||
subtitle = "Later",
|
|
||||||
artworkUrl = null,
|
|
||||||
isCurrent = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
initiallyExpanded = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
|
||||||
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun backOnVisibleControls_hidesOverlayImmediately() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.Back)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun escapeOnVisibleControls_hidesOverlayImmediately() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.Escape)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.Enter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
|
|
||||||
composeRule.mainClock.autoAdvance = false
|
|
||||||
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.mainClock.advanceTimeByFrame()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
|
||||||
|
|
||||||
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
|
|
||||||
composeRule.mainClock.advanceTimeByFrame()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
|
||||||
|
|
||||||
composeRule.mainClock.advanceTimeBy(1)
|
|
||||||
composeRule.mainClock.advanceTimeBy(300)
|
|
||||||
composeRule.mainClock.advanceTimeByFrame()
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
|
||||||
|
|
||||||
composeRule.mainClock.autoAdvance = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState().copy(isPlaying = false),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"0"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"1"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.Back)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun leftOnTrackPanel_keepsFocusInsidePanel() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun trackButtonsWithoutOptions_areDisabled() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState().copy(
|
|
||||||
audioTracks = emptyList(),
|
|
||||||
textTracks = emptyList(),
|
|
||||||
qualityTracks = emptyList(),
|
|
||||||
selectedAudioTrackId = null,
|
|
||||||
selectedTextTrackId = null,
|
|
||||||
selectedQualityTrackId = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun OverlayHost(
|
|
||||||
uiState: PlayerUiState,
|
|
||||||
initiallyExpanded: Boolean = false,
|
|
||||||
requestFocus: Boolean = false
|
|
||||||
) {
|
|
||||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
LaunchedEffect(requestFocus) {
|
|
||||||
if (requestFocus) {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
|
||||||
TvPlayerControlsOverlay(
|
|
||||||
uiState = uiState,
|
|
||||||
focusRequester = focusRequester,
|
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
|
||||||
onPlayPause = {},
|
|
||||||
onSeek = { _ -> },
|
|
||||||
onSeekRelative = { _ -> },
|
|
||||||
onSeekLiveEdge = {},
|
|
||||||
onNext = {},
|
|
||||||
onPrevious = {},
|
|
||||||
onOpenAudioPanel = {},
|
|
||||||
onOpenSubtitlesPanel = {},
|
|
||||||
onOpenQualityPanel = {},
|
|
||||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
|
||||||
onCollapsePlaylist = { isPlaylistExpanded = false },
|
|
||||||
onSelectQueueItem = { _ -> },
|
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrackPanelHost(
|
|
||||||
uiState: PlayerUiState,
|
|
||||||
initialControlsVisible: Boolean = true
|
|
||||||
) {
|
|
||||||
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
|
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
|
||||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
|
||||||
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
|
||||||
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
|
||||||
val rootFocusRequester = remember { FocusRequester() }
|
|
||||||
val controlsFocusRequester = remember { FocusRequester() }
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val closeTrackPanel: () -> Unit = {
|
|
||||||
trackPanelType?.let { panelType ->
|
|
||||||
pendingTrackButtonFocus = panelType
|
|
||||||
trackPanelType = null
|
|
||||||
controlsVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
pauseWithoutControlsCount += 1
|
|
||||||
stopFeedbackVisible = true
|
|
||||||
stopFeedbackRequestId += 1
|
|
||||||
}
|
|
||||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
resumeWithoutControlsCount += 1
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
|
||||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
|
||||||
if (trackPanelType != null) return@LaunchedEffect
|
|
||||||
when (pendingFocus) {
|
|
||||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
|
||||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
|
||||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
pendingTrackButtonFocus = null
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(stopFeedbackRequestId) {
|
|
||||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
|
||||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, trackPanelType) {
|
|
||||||
if (controlsVisible || trackPanelType != null) {
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
} else {
|
|
||||||
rootFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 960.dp, height = 540.dp)
|
|
||||||
.focusRequester(rootFocusRequester)
|
|
||||||
.focusable()
|
|
||||||
.testTag(TvPlayerRootTag)
|
|
||||||
.onPreviewKeyEvent { event ->
|
|
||||||
handleTvPlayerRootKeyEvent(
|
|
||||||
event = event,
|
|
||||||
isPlaying = uiState.isPlaying,
|
|
||||||
controlsVisible = controlsVisible,
|
|
||||||
isPlaylistExpanded = false,
|
|
||||||
trackPanelType = trackPanelType,
|
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
|
||||||
onCollapsePlaylist = { controlsVisible = true },
|
|
||||||
onHideControls = { controlsVisible = false },
|
|
||||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
|
||||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
|
||||||
onSeekRelative = { _ -> controlsVisible = true },
|
|
||||||
onShowControls = { controlsVisible = true },
|
|
||||||
onTogglePlayPause = { controlsVisible = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (controlsVisible || trackPanelType != null) {
|
|
||||||
TvPlayerControlsOverlay(
|
|
||||||
uiState = uiState,
|
|
||||||
focusRequester = controlsFocusRequester,
|
|
||||||
isPlaylistExpanded = false,
|
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
|
||||||
onPlayPause = {},
|
|
||||||
onSeek = { _ -> },
|
|
||||||
onSeekRelative = { _ -> },
|
|
||||||
onSeekLiveEdge = {},
|
|
||||||
onNext = {},
|
|
||||||
onPrevious = {},
|
|
||||||
onOpenAudioPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.AUDIO
|
|
||||||
},
|
|
||||||
onOpenSubtitlesPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.SUBTITLES
|
|
||||||
},
|
|
||||||
onOpenQualityPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.QUALITY
|
|
||||||
},
|
|
||||||
onExpandPlaylist = {},
|
|
||||||
onCollapsePlaylist = {},
|
|
||||||
onSelectQueueItem = { _ -> },
|
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = stopFeedbackVisible,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
) {
|
|
||||||
TvPlayerHiddenStopFeedback()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(TvPlayerHiddenPauseCountTag)
|
|
||||||
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(TvPlayerHiddenResumeCountTag)
|
|
||||||
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
trackPanelType?.let { panelType ->
|
|
||||||
TvTrackSelectionPanel(
|
|
||||||
panelType = panelType,
|
|
||||||
uiState = uiState,
|
|
||||||
onSelect = { closeTrackPanel() },
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
|
||||||
isPlaying = true,
|
|
||||||
title = "Sample Player",
|
|
||||||
subtitle = "Season 1",
|
|
||||||
durationMs = 3_600_000,
|
|
||||||
positionMs = 120_000,
|
|
||||||
bufferedMs = 240_000,
|
|
||||||
queue = listOf(
|
|
||||||
QueueItemUi(
|
|
||||||
id = "played",
|
|
||||||
title = "Already Played",
|
|
||||||
subtitle = "Episode 0",
|
|
||||||
artworkUrl = null,
|
|
||||||
isCurrent = false
|
|
||||||
),
|
|
||||||
QueueItemUi(
|
|
||||||
id = "current",
|
|
||||||
title = "Currently Playing",
|
|
||||||
subtitle = "Episode 1",
|
|
||||||
artworkUrl = null,
|
|
||||||
isCurrent = true
|
|
||||||
),
|
|
||||||
QueueItemUi(
|
|
||||||
id = "next",
|
|
||||||
title = "Episode 2",
|
|
||||||
subtitle = "Up next",
|
|
||||||
artworkUrl = null,
|
|
||||||
isCurrent = false
|
|
||||||
)
|
|
||||||
),
|
|
||||||
audioTracks = listOf(
|
|
||||||
TrackOption(
|
|
||||||
id = "audio_en",
|
|
||||||
label = "English 5.1",
|
|
||||||
language = "en",
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = 6,
|
|
||||||
height = null,
|
|
||||||
groupIndex = 0,
|
|
||||||
trackIndex = 0,
|
|
||||||
type = TrackType.AUDIO,
|
|
||||||
isOff = false
|
|
||||||
),
|
|
||||||
TrackOption(
|
|
||||||
id = "audio_hu",
|
|
||||||
label = "Hungarian Stereo",
|
|
||||||
language = "hu",
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = 2,
|
|
||||||
height = null,
|
|
||||||
groupIndex = 0,
|
|
||||||
trackIndex = 1,
|
|
||||||
type = TrackType.AUDIO,
|
|
||||||
isOff = false
|
|
||||||
)
|
|
||||||
),
|
|
||||||
textTracks = listOf(
|
|
||||||
TrackOption(
|
|
||||||
id = "text_off",
|
|
||||||
label = "Off",
|
|
||||||
language = null,
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = null,
|
|
||||||
height = null,
|
|
||||||
groupIndex = 1,
|
|
||||||
trackIndex = -1,
|
|
||||||
type = TrackType.TEXT,
|
|
||||||
isOff = true
|
|
||||||
),
|
|
||||||
TrackOption(
|
|
||||||
id = "text_en",
|
|
||||||
label = "English CC",
|
|
||||||
language = "en",
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = null,
|
|
||||||
height = null,
|
|
||||||
groupIndex = 1,
|
|
||||||
trackIndex = 0,
|
|
||||||
type = TrackType.TEXT,
|
|
||||||
isOff = false
|
|
||||||
)
|
|
||||||
),
|
|
||||||
qualityTracks = listOf(
|
|
||||||
TrackOption(
|
|
||||||
id = "quality_auto",
|
|
||||||
label = "Auto",
|
|
||||||
language = null,
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = null,
|
|
||||||
height = 1080,
|
|
||||||
groupIndex = 2,
|
|
||||||
trackIndex = 0,
|
|
||||||
type = TrackType.VIDEO,
|
|
||||||
isOff = false
|
|
||||||
),
|
|
||||||
TrackOption(
|
|
||||||
id = "quality_720",
|
|
||||||
label = "720p",
|
|
||||||
language = null,
|
|
||||||
bitrate = null,
|
|
||||||
channelCount = null,
|
|
||||||
height = 720,
|
|
||||||
groupIndex = 2,
|
|
||||||
trackIndex = 1,
|
|
||||||
type = TrackType.VIDEO,
|
|
||||||
isOff = false
|
|
||||||
)
|
|
||||||
),
|
|
||||||
selectedAudioTrackId = "audio_hu",
|
|
||||||
selectedTextTrackId = "text_en",
|
|
||||||
selectedQualityTrackId = "quality_auto"
|
|
||||||
)
|
|
||||||
@@ -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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import org.junit.Assert.assertEquals
|
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.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
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.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -104,7 +105,9 @@ class EpisodeScreenContentTest {
|
|||||||
return Episode(
|
return Episode(
|
||||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
seriesName = "Severance",
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
|
seasonIndex = 2,
|
||||||
index = 4,
|
index = 4,
|
||||||
title = "The You You Are",
|
title = "The You You Are",
|
||||||
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",
|
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.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.layout.Box
|
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.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
|
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
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.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -68,7 +67,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = emptyList(),
|
nextUp = emptyList(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -80,7 +78,6 @@ class TvHomeContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
|
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
|
||||||
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
|
||||||
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -92,7 +89,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatchingRow(),
|
continueWatching = sampleContinueWatchingRow(),
|
||||||
nextUp = emptyList(),
|
nextUp = emptyList(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -126,7 +122,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = emptyList(),
|
nextUp = emptyList(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -156,7 +151,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = sampleNextUp(),
|
nextUp = sampleNextUp(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -196,7 +190,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = emptyList(),
|
nextUp = emptyList(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -228,7 +221,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = sampleNextUp(),
|
nextUp = sampleNextUp(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -266,7 +258,6 @@ class TvHomeContentTest {
|
|||||||
libraryContent = sampleLibraryContent(),
|
libraryContent = sampleLibraryContent(),
|
||||||
continueWatching = sampleContinueWatching(),
|
continueWatching = sampleContinueWatching(),
|
||||||
nextUp = sampleNextUpRow(),
|
nextUp = sampleNextUpRow(),
|
||||||
serverUrl = "",
|
|
||||||
onMovieSelected = {},
|
onMovieSelected = {},
|
||||||
onSeriesSelected = {},
|
onSeriesSelected = {},
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
@@ -295,11 +286,49 @@ class TvHomeContentTest {
|
|||||||
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
|
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(
|
return listOf(
|
||||||
ContinueWatchingItem(
|
MovieUiModel(
|
||||||
type = BaseItemKind.MOVIE,
|
sampleMovie(
|
||||||
movie = sampleMovie(
|
|
||||||
id = "11111111-1111-1111-1111-111111111111",
|
id = "11111111-1111-1111-1111-111111111111",
|
||||||
title = "Blade Runner 2049",
|
title = "Blade Runner 2049",
|
||||||
progress = 42.0
|
progress = 42.0
|
||||||
@@ -308,19 +337,17 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
|
private fun sampleContinueWatchingRow(): List<MediaUiModel> {
|
||||||
return listOf(
|
return listOf(
|
||||||
ContinueWatchingItem(
|
MovieUiModel(
|
||||||
type = BaseItemKind.MOVIE,
|
sampleMovie(
|
||||||
movie = sampleMovie(
|
|
||||||
id = "11111111-1111-1111-1111-111111111111",
|
id = "11111111-1111-1111-1111-111111111111",
|
||||||
title = "Blade Runner 2049",
|
title = "Blade Runner 2049",
|
||||||
progress = 42.0
|
progress = 42.0
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
ContinueWatchingItem(
|
MovieUiModel(
|
||||||
type = BaseItemKind.MOVIE,
|
sampleMovie(
|
||||||
movie = sampleMovie(
|
|
||||||
id = "55555555-5555-5555-5555-555555555555",
|
id = "55555555-5555-5555-5555-555555555555",
|
||||||
title = "Mad Max: Fury Road",
|
title = "Mad Max: Fury Road",
|
||||||
progress = 8.0
|
progress = 8.0
|
||||||
@@ -329,10 +356,10 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleNextUp(): List<NextUpItem> {
|
private fun sampleNextUp(): List<MediaUiModel> {
|
||||||
return listOf(
|
return listOf(
|
||||||
NextUpItem(
|
EpisodeUiModel(
|
||||||
episode = sampleEpisode(
|
sampleEpisode(
|
||||||
id = "66666666-6666-6666-6666-666666666666",
|
id = "66666666-6666-6666-6666-666666666666",
|
||||||
title = "The Train Job"
|
title = "The Train Job"
|
||||||
)
|
)
|
||||||
@@ -340,16 +367,16 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleNextUpRow(): List<NextUpItem> {
|
private fun sampleNextUpRow(): List<MediaUiModel> {
|
||||||
return listOf(
|
return listOf(
|
||||||
NextUpItem(
|
EpisodeUiModel(
|
||||||
episode = sampleEpisode(
|
sampleEpisode(
|
||||||
id = "66666666-6666-6666-6666-666666666666",
|
id = "66666666-6666-6666-6666-666666666666",
|
||||||
title = "The Train Job"
|
title = "The Train Job"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
NextUpItem(
|
EpisodeUiModel(
|
||||||
episode = sampleEpisode(
|
sampleEpisode(
|
||||||
id = "77777777-7777-7777-7777-777777777777",
|
id = "77777777-7777-7777-7777-777777777777",
|
||||||
title = "Safe",
|
title = "Safe",
|
||||||
releaseDate = "2024-02-09"
|
releaseDate = "2024-02-09"
|
||||||
@@ -358,26 +385,26 @@ class TvHomeContentTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleLibraries(): List<LibraryItem> {
|
private fun sampleLibraries(): List<LibraryUiModel> {
|
||||||
return listOf(
|
return listOf(
|
||||||
LibraryItem(
|
LibraryUiModel(
|
||||||
id = sampleLibraryId(),
|
id = sampleLibraryId(),
|
||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = CollectionType.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = false
|
isEmpty = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
private fun sampleLibraryContent(): Map<UUID, List<MediaUiModel>> {
|
||||||
val libraryId = sampleLibraryId()
|
val libraryId = sampleLibraryId()
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
libraryId to listOf(
|
libraryId to listOf(
|
||||||
PosterItem(
|
MovieUiModel(
|
||||||
type = BaseItemKind.MOVIE,
|
sampleMovie(
|
||||||
movie = sampleMovie(
|
|
||||||
id = "44444444-4444-4444-4444-444444444444",
|
id = "44444444-4444-4444-4444-444444444444",
|
||||||
title = "Arrival",
|
title = "Arrival",
|
||||||
progress = null,
|
progress = null,
|
||||||
@@ -399,7 +426,9 @@ class TvHomeContentTest {
|
|||||||
return Episode(
|
return Episode(
|
||||||
id = UUID.fromString(id),
|
id = UUID.fromString(id),
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
seriesName = "Firefly",
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
|
seasonIndex = 1,
|
||||||
index = 1,
|
index = 1,
|
||||||
title = title,
|
title = title,
|
||||||
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
||||||
@@ -462,4 +491,11 @@ class TvHomeContentTest {
|
|||||||
.boundsInRoot
|
.boundsInRoot
|
||||||
.top
|
.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.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.layout.Box
|
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.performSemanticsAction
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
@OptIn(ExperimentalTestApi::class)
|
||||||
class TvNavigationDrawerTest {
|
class TvNavigationDrawerTest {
|
||||||
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun tvNavigationDrawer_updatesSelectedDestination() {
|
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 {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
TvNavigationDrawer(
|
TvNavigationDrawer(
|
||||||
destinations = listOf(
|
destinations = listOf(
|
||||||
TvDrawerDestinationItem(
|
TvDrawerDestinationItem(
|
||||||
destination = TvDrawerDestination.HOME,
|
destination = Route.Home,
|
||||||
label = "Home",
|
label = "Home",
|
||||||
icon = Icons.Outlined.Home
|
icon = Icons.Outlined.Home
|
||||||
),
|
),
|
||||||
TvDrawerDestinationItem(
|
TvDrawerDestinationItem(
|
||||||
destination = TvDrawerDestination.LIBRARIES,
|
destination = libraryRoute,
|
||||||
label = "Libraries",
|
label = "Movies",
|
||||||
icon = Icons.Outlined.Collections
|
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.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import hu.bbara.purefin.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 hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -20,22 +20,24 @@ class TvLibrariesOverviewScreenTest {
|
|||||||
@Test
|
@Test
|
||||||
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
|
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
|
||||||
val libraries = listOf(
|
val libraries = listOf(
|
||||||
LibraryItem(
|
LibraryUiModel(
|
||||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = CollectionType.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = false
|
isEmpty = false
|
||||||
),
|
),
|
||||||
LibraryItem(
|
LibraryUiModel(
|
||||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||||
name = "Shows",
|
name = "Shows",
|
||||||
type = CollectionType.TVSHOWS,
|
type = LibraryKind.SERIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = false
|
isEmpty = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
var openedLibrary: LibraryItem? = null
|
var openedLibrary: LibraryUiModel? = null
|
||||||
|
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
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.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
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.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package hu.bbara.purefin.app.content.series
|
package hu.bbara.purefin.ui.screen.series
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
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.performKeyInput
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
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.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -28,7 +30,7 @@ class SeriesScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
@@ -42,8 +44,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||||
@@ -64,7 +65,6 @@ class SeriesScreenContentTest {
|
|||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
@@ -85,7 +85,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
|
||||||
composeRule.onRoot().performKeyInput {
|
composeRule.onRoot().performKeyInput {
|
||||||
pressKey(Key.DirectionCenter)
|
pressKey(Key.DirectionCenter)
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,9 @@ class SeriesScreenContentTest {
|
|||||||
Episode(
|
Episode(
|
||||||
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
seriesName = "Severance",
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
|
seasonIndex = 1,
|
||||||
index = 1,
|
index = 1,
|
||||||
title = "Good News About Hell",
|
title = "Good News About Hell",
|
||||||
synopsis = "Mark is promoted after an unexpected tragedy.",
|
synopsis = "Mark is promoted after an unexpected tragedy.",
|
||||||
@@ -136,7 +138,9 @@ class SeriesScreenContentTest {
|
|||||||
Episode(
|
Episode(
|
||||||
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
|
seriesName = "Severance",
|
||||||
seasonId = seasonId,
|
seasonId = seasonId,
|
||||||
|
seasonIndex = 1,
|
||||||
index = 2,
|
index = 2,
|
||||||
title = "Half Loop",
|
title = "Half Loop",
|
||||||
synopsis = "Mark takes the team out for a sanctioned dinner.",
|
synopsis = "Mark takes the team out for a sanctioned dinner.",
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".TvApplication"
|
android:name="hu.bbara.purefin.tv.TvApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:banner="@drawable/banner"
|
android:banner="@drawable/banner"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -23,9 +25,13 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/purefin_logo_round"
|
android:roundIcon="@mipmap/purefin_logo_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Purefin">
|
android:theme="@style/Theme.Purefin">
|
||||||
|
<meta-data
|
||||||
|
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||||
|
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".TvActivity"
|
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.Purefin">
|
android:theme="@style/Theme.Purefin">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -34,6 +40,9 @@
|
|||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
package hu.bbara.purefin.app.content.episode
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.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.image.JellyfinImageHelper
|
|
||||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
|
||||||
import hu.bbara.purefin.core.data.navigation.Route
|
|
||||||
import hu.bbara.purefin.core.model.Episode
|
|
||||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TvEpisodeScreen(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
TvEpisodeScreenContent(
|
|
||||||
episode = selectedEpisode,
|
|
||||||
seriesTitle = seriesTitle.value,
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
TvMediaDetailBodyBox(
|
|
||||||
backgroundImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
|
|
||||||
modifier = it
|
|
||||||
) {
|
|
||||||
TvEpisodeHeroSection(
|
|
||||||
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 TvMovieHeroSection(
|
|
||||||
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 TvMovieScreen(
|
|
||||||
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
LaunchedEffect(movie.id) {
|
|
||||||
viewModel.selectMovie(movie.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
val movieItem = viewModel.movie.collectAsState()
|
|
||||||
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
TvMediaDetailBodyBox(
|
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
|
||||||
modifier = it
|
|
||||||
) {
|
|
||||||
TvMovieHeroSection(
|
|
||||||
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 TvSeriesMetaChips(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 TvSeasonTabs(
|
|
||||||
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 ->
|
|
||||||
TvSeasonTab(
|
|
||||||
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 TvSeasonTab(
|
|
||||||
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 TvEpisodeCarousel(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 ->
|
|
||||||
TvEpisodeCard(episode = episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun TvSeriesHeroSection(
|
|
||||||
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))
|
|
||||||
TvSeriesMetaChips(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 TvEpisodeCard(
|
|
||||||
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 TvSeriesScreen(
|
|
||||||
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()) {
|
|
||||||
TvSeriesScreenContent(
|
|
||||||
series = seriesData,
|
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
PurefinWaitingScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun TvSeriesScreenContent(
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
TvSeriesHeroSection(
|
|
||||||
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 {
|
|
||||||
TvSeasonTabs(
|
|
||||||
seasons = series.seasons,
|
|
||||||
selectedSeason = selectedSeason,
|
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
|
||||||
onSelect = { selectedSeason = it },
|
|
||||||
modifier = it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
TvEpisodeCarousel(
|
|
||||||
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,76 +0,0 @@
|
|||||||
package hu.bbara.purefin.common.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PurefinPasswordField(
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
leadingIcon: ImageVector,
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
val showField = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
TextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp)),
|
|
||||||
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
|
|
||||||
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) },
|
|
||||||
trailingIcon =
|
|
||||||
{
|
|
||||||
IconButton(
|
|
||||||
onClick = { showField.value = !showField.value },
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = scheme.surfaceVariant,
|
|
||||||
unfocusedContainerColor = scheme.surfaceVariant,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
cursorColor = scheme.primary,
|
|
||||||
focusedTextColor = scheme.onSurface,
|
|
||||||
unfocusedTextColor = scheme.onSurface
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package hu.bbara.purefin.common.ui
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Movie
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PurefinWaitingScreen(
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val accentColor = scheme.primary
|
|
||||||
val backgroundColor = scheme.background
|
|
||||||
val surfaceColor = scheme.surface
|
|
||||||
val textPrimary = scheme.onSurface
|
|
||||||
val textSecondary = scheme.onSurfaceVariant
|
|
||||||
|
|
||||||
val transition = rememberInfiniteTransition(label = "waiting-pulse")
|
|
||||||
val pulseScale = transition.animateFloat(
|
|
||||||
initialValue = 0.9f,
|
|
||||||
targetValue = 1.15f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulse-scale"
|
|
||||||
)
|
|
||||||
val pulseAlpha = transition.animateFloat(
|
|
||||||
initialValue = 0.2f,
|
|
||||||
targetValue = 0.6f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulse-alpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
val gradient = Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
accentColor.copy(alpha = 0.28f),
|
|
||||||
backgroundColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(gradient)
|
|
||||||
.padding(24.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.background(surfaceColor.copy(alpha = 0.92f))
|
|
||||||
.padding(horizontal = 28.dp, vertical = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(86.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = pulseScale.value
|
|
||||||
scaleY = pulseScale.value
|
|
||||||
}
|
|
||||||
.alpha(pulseAlpha.value)
|
|
||||||
.border(
|
|
||||||
width = 2.dp,
|
|
||||||
color = accentColor.copy(alpha = 0.6f),
|
|
||||||
shape = RoundedCornerShape(26.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.background(accentColor),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Movie,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = scheme.onPrimary,
|
|
||||||
modifier = Modifier.size(40.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Just a moment",
|
|
||||||
color = textPrimary,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "I am doing all I can...",
|
|
||||||
color = textSecondary,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
WaitingDots(accentColor = accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) {
|
|
||||||
val transition = rememberInfiniteTransition(label = "waiting-dots")
|
|
||||||
val firstAlpha = transition.animateFloat(
|
|
||||||
initialValue = 0.3f,
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "dot-1"
|
|
||||||
)
|
|
||||||
val secondAlpha = transition.animateFloat(
|
|
||||||
initialValue = 0.3f,
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "dot-2"
|
|
||||||
)
|
|
||||||
val thirdAlpha = transition.animateFloat(
|
|
||||||
initialValue = 0.3f,
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "dot-3"
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
WaitingDot(alpha = firstAlpha.value, color = accentColor)
|
|
||||||
WaitingDot(alpha = secondAlpha.value, color = accentColor)
|
|
||||||
WaitingDot(alpha = thirdAlpha.value, color = accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WaitingDot(alpha: Float, color: Color) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
val scale = 0.7f + (alpha * 0.3f)
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
}
|
|
||||||
.alpha(alpha)
|
|
||||||
.background(color, CircleShape)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,81 +0,0 @@
|
|||||||
package hu.bbara.purefin.common.ui.components
|
|
||||||
|
|
||||||
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.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Check
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun WatchStateIndicator(
|
|
||||||
watched: Boolean,
|
|
||||||
started: Boolean,
|
|
||||||
watchedColor: Color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
watchedBackgroundColor: Color = MaterialTheme.colorScheme.primary,
|
|
||||||
startedColor: Color = MaterialTheme.colorScheme.onSecondary,
|
|
||||||
startedBackgroundColor: Color = MaterialTheme.colorScheme.secondary,
|
|
||||||
size: Int = 24,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
|
|
||||||
if (watched.not() && started.not()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val foregroundColor = if (watched) watchedColor.copy(alpha = 0.8f) else startedColor.copy(alpha = 0.3f)
|
|
||||||
val backgroundColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.3f)
|
|
||||||
val borderColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.8f)
|
|
||||||
|
|
||||||
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = modifier
|
|
||||||
.border(1.dp, borderColor, CircleShape)
|
|
||||||
.background(backgroundColor, CircleShape)
|
|
||||||
.size(size.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
) {
|
|
||||||
if (watched) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Check,
|
|
||||||
contentDescription = "Check",
|
|
||||||
tint = foregroundColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(1.dp)
|
|
||||||
.matchParentSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun WatchStateIndicatorPreview() {
|
|
||||||
Column() {
|
|
||||||
WatchStateIndicator(
|
|
||||||
watched = false,
|
|
||||||
started = false
|
|
||||||
)
|
|
||||||
WatchStateIndicator(
|
|
||||||
watched = true,
|
|
||||||
started = false
|
|
||||||
)
|
|
||||||
WatchStateIndicator(
|
|
||||||
watched = false,
|
|
||||||
started = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 androidx.navigation3.runtime.EntryProviderScope
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -6,7 +6,15 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||||
import dagger.multibindings.IntoSet
|
import dagger.multibindings.IntoSet
|
||||||
import hu.bbara.purefin.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
|
@Module
|
||||||
@InstallIn(ActivityRetainedComponent::class)
|
@InstallIn(ActivityRetainedComponent::class)
|
||||||
@@ -53,4 +61,10 @@ object TvNavigationModule {
|
|||||||
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||||
tvLibrarySection()
|
tvLibrarySection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IntoSet
|
||||||
|
@Provides
|
||||||
|
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||||
|
tvSettingsSection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package hu.bbara.purefin.tv.di
|
package hu.bbara.purefin.modules
|
||||||
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily
|
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@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.content.pm.ApplicationInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -11,15 +13,20 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
@@ -36,16 +43,19 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||||
import hu.bbara.purefin.core.data.client.JellyfinApiClient
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
|
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||||
import hu.bbara.purefin.core.data.navigation.NavigationCommand
|
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||||
import hu.bbara.purefin.core.data.navigation.NavigationManager
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.core.data.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||||
import hu.bbara.purefin.login.ui.LoginScreen
|
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.AppTheme
|
||||||
import hu.bbara.purefin.ui.theme.backgroundDark
|
import hu.bbara.purefin.ui.theme.backgroundDark
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -66,7 +76,7 @@ class TvActivity : ComponentActivity() {
|
|||||||
lateinit var navigationManager: NavigationManager
|
lateinit var navigationManager: NavigationManager
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var jellyfinApiClient: JellyfinApiClient
|
lateinit var sessionBootstrapper: SessionBootstrapper
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var authInterceptor: JellyfinAuthInterceptor
|
lateinit var authInterceptor: JellyfinAuthInterceptor
|
||||||
@@ -94,7 +104,7 @@ class TvActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun init() {
|
private suspend fun init() {
|
||||||
jellyfinApiClient.updateApiClient()
|
sessionBootstrapper.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun configureImageLoader() {
|
private fun configureImageLoader() {
|
||||||
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
|
|||||||
fun MainApp(
|
fun MainApp(
|
||||||
userSessionRepository: UserSessionRepository,
|
userSessionRepository: UserSessionRepository,
|
||||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||||
navigationManager: NavigationManager
|
navigationManager: NavigationManager,
|
||||||
|
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
var sessionLoaded by remember { mutableStateOf(false) }
|
var sessionLoaded by remember { mutableStateOf(false) }
|
||||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
var showExitDialog by remember { mutableStateOf(false) }
|
||||||
|
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
userSessionRepository.isLoggedIn.collect {
|
userSessionRepository.isLoggedIn.collect {
|
||||||
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionLoaded) {
|
LaunchedEffect(updateViewModel) {
|
||||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
updateViewModel.checkForUpdatesOnAppOpen()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoggedIn) {
|
LaunchedEffect(updateViewModel, context) {
|
||||||
|
updateViewModel.snackbarMessages.collect { message ->
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionLoaded) {
|
||||||
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||||
|
} else if (isLoggedIn) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||||
val appEntryProvider = entryProvider {
|
val appEntryProvider = entryProvider {
|
||||||
@@ -161,7 +185,13 @@ class TvActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(navigationManager, backStack) {
|
LaunchedEffect(navigationManager, backStack) {
|
||||||
navigationManager.commands.collect { command ->
|
navigationManager.commands.collect { command ->
|
||||||
when (command) {
|
when (command) {
|
||||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
NavigationCommand.Pop -> {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
} else {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||||
is NavigationCommand.ReplaceAll -> {
|
is NavigationCommand.ReplaceAll -> {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
@@ -171,13 +201,23 @@ class TvActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size == 1) {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNavigationManager provides navigationManager,
|
LocalNavigationManager provides navigationManager,
|
||||||
LocalNavigationBackStack provides backStack.toList()
|
LocalNavigationBackStack provides backStack.toList()
|
||||||
) {
|
) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = { navigationManager.pop() },
|
onBack = {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
navigationManager.pop()
|
||||||
|
} else {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
fadeIn(
|
fadeIn(
|
||||||
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LoginScreen()
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
TvLoginScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
availableUpdate?.let { update ->
|
||||||
|
TvUpdateAvailableDialog(
|
||||||
|
update = update,
|
||||||
|
onAccept = updateViewModel::acceptUpdate,
|
||||||
|
onDecline = updateViewModel::declineUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExitDialog) {
|
||||||
|
ExitAppDialog(
|
||||||
|
onCloseConfirmed = { finishAndRemoveTask() },
|
||||||
|
onDismiss = { showExitDialog = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TvUpdateAvailableDialog(
|
||||||
|
update: AppUpdateInfo,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit
|
||||||
|
) {
|
||||||
|
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||||
|
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||||
|
val updateText = if (releaseNotes == null) {
|
||||||
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||||
|
} else {
|
||||||
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDecline,
|
||||||
|
title = { Text("Update available") },
|
||||||
|
text = { Text(updateText) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onAccept) {
|
||||||
|
Text("Install")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDecline) {
|
||||||
|
Text("Not now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class TvApplication : Application()
|
class TvApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
PurefinLogger.initialize(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.TvEpisodeScreen
|
|
||||||
import hu.bbara.purefin.app.content.movie.TvMovieScreen
|
|
||||||
import hu.bbara.purefin.app.content.series.TvSeriesScreen
|
|
||||||
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 ->
|
|
||||||
TvMovieScreen(movie = route.item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun EntryProviderScope<Route>.tvSeriesSection() {
|
|
||||||
entry<Route.SeriesRoute> { route ->
|
|
||||||
TvSeriesScreen(series = route.item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
|
||||||
entry<Route.EpisodeRoute> { route ->
|
|
||||||
TvEpisodeScreen(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,416 +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.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.outlined.Pause
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.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.platform.testTag
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
|
||||||
import androidx.media3.ui.PlayerView
|
|
||||||
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
|
|
||||||
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.TvTrackPanelType
|
|
||||||
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
|
||||||
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 uiState by viewModel.uiState.collectAsState()
|
|
||||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
|
||||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
|
||||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
|
||||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(isPlaylistExpanded) {
|
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(trackPanelType) {
|
|
||||||
viewModel.setControlsAutoHideBlocked(
|
|
||||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
|
||||||
trackPanelType != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
|
||||||
val controlsFocusRequester = remember { FocusRequester() }
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val expandPlaylist: () -> Unit = {
|
|
||||||
if (!isPlaylistExpanded) {
|
|
||||||
isPlaylistExpanded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val collapsePlaylistToControls: () -> Unit = {
|
|
||||||
if (isPlaylistExpanded) {
|
|
||||||
isPlaylistExpanded = false
|
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val showTvControls: () -> Unit = {
|
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
|
||||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
viewModel.pausePlayback()
|
|
||||||
stopFeedbackVisible = true
|
|
||||||
stopFeedbackRequestId += 1
|
|
||||||
}
|
|
||||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
viewModel.resumePlayback()
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
|
||||||
viewModel.seekTo(positionMs)
|
|
||||||
showTvControls()
|
|
||||||
}
|
|
||||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
|
||||||
viewModel.seekBy(deltaMs)
|
|
||||||
showTvControls()
|
|
||||||
}
|
|
||||||
val 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)
|
|
||||||
}
|
|
||||||
val closeTrackPanel: () -> Unit = {
|
|
||||||
trackPanelType?.let { panelType ->
|
|
||||||
pendingTrackButtonFocus = panelType
|
|
||||||
trackPanelType = null
|
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, controlsAutoHideBlocked) {
|
|
||||||
if (controlsAutoHideBlocked) return@LaunchedEffect
|
|
||||||
if (controlsVisible) {
|
|
||||||
controlsFocusRequester.requestFocus()
|
|
||||||
} else {
|
|
||||||
hiddenControlFocusRequester.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
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(stopFeedbackRequestId) {
|
|
||||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
|
||||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
|
|
||||||
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
|
||||||
when {
|
|
||||||
trackPanelType != null -> closeTrackPanel()
|
|
||||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
|
||||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
|
||||||
else -> onBack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
.focusRequester(hiddenControlFocusRequester)
|
|
||||||
.onPreviewKeyEvent { event ->
|
|
||||||
handleTvPlayerRootKeyEvent(
|
|
||||||
event = event,
|
|
||||||
isPlaying = uiState.isPlaying,
|
|
||||||
controlsVisible = controlsVisible,
|
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
|
||||||
trackPanelType = trackPanelType,
|
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
|
||||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
|
||||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
|
||||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
|
||||||
onSeekRelative = seekByAndShowControls,
|
|
||||||
onShowControls = showTvControls,
|
|
||||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.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 || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
TvPlayerControlsOverlay(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
uiState = uiState,
|
|
||||||
focusRequester = controlsFocusRequester,
|
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
|
||||||
onPlayPause = togglePlayPauseAndShowControls,
|
|
||||||
onSeek = seekAndShowControls,
|
|
||||||
onSeekRelative = seekByAndShowControls,
|
|
||||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
|
||||||
onNext = nextAndShowControls,
|
|
||||||
onPrevious = previousAndShowControls,
|
|
||||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
|
||||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
|
||||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
|
||||||
onExpandPlaylist = expandPlaylist,
|
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
|
||||||
onSelectQueueItem = { id ->
|
|
||||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
collapsePlaylistToControls()
|
|
||||||
},
|
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TvPlayerLoadingErrorEndCard(
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
uiState = uiState,
|
|
||||||
onRetry = { viewModel.retry() },
|
|
||||||
onNext = nextAndShowControls,
|
|
||||||
onReplay = {
|
|
||||||
viewModel.seekTo(0L)
|
|
||||||
togglePlayPauseAndShowControls()
|
|
||||||
},
|
|
||||||
onDismissError = { viewModel.clearError() }
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = stopFeedbackVisible,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
) {
|
|
||||||
TvPlayerHiddenStopFeedback()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun handleTvPlayerRootKeyEvent(
|
|
||||||
event: androidx.compose.ui.input.key.KeyEvent,
|
|
||||||
isPlaying: Boolean,
|
|
||||||
controlsVisible: Boolean,
|
|
||||||
isPlaylistExpanded: Boolean,
|
|
||||||
trackPanelType: TvTrackPanelType?,
|
|
||||||
onCloseTrackPanel: () -> Unit,
|
|
||||||
onCollapsePlaylist: () -> Unit,
|
|
||||||
onHideControls: () -> Unit,
|
|
||||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
|
||||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
|
||||||
onSeekRelative: (Long) -> Unit,
|
|
||||||
onShowControls: () -> Unit,
|
|
||||||
onTogglePlayPause: () -> Unit
|
|
||||||
): Boolean {
|
|
||||||
if (event.type != KeyEventType.KeyDown) return false
|
|
||||||
|
|
||||||
if (event.key == Key.Back || event.key == Key.Escape) {
|
|
||||||
return when {
|
|
||||||
trackPanelType != null -> {
|
|
||||||
onCloseTrackPanel()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaylistExpanded -> {
|
|
||||||
onCollapsePlaylist()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
controlsVisible -> {
|
|
||||||
onHideControls()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controlsVisible) {
|
|
||||||
return when (event.key) {
|
|
||||||
Key.DirectionLeft -> {
|
|
||||||
onSeekRelative(-10_000)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionRight -> {
|
|
||||||
onSeekRelative(10_000)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionUp, Key.DirectionDown -> {
|
|
||||||
onShowControls()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionCenter, Key.Enter -> {
|
|
||||||
if (isPlaying) {
|
|
||||||
onPausePlaybackWithoutShowingControls()
|
|
||||||
} else {
|
|
||||||
onResumePlaybackWithoutShowingControls()
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun TvPlayerHiddenStopFeedback(
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
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 = "Pause playback",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(72.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +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.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.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun TvIconButton(
|
|
||||||
icon: ImageVector,
|
|
||||||
contentDescription: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
size: Int = 52,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
.alpha(if (enabled) 1f else 0.4f)
|
|
||||||
.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)
|
|
||||||
)
|
|
||||||
.focusProperties { canFocus = enabled }
|
|
||||||
.semantics {
|
|
||||||
if (!enabled) {
|
|
||||||
disabled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.clickable(enabled = enabled) { onClick() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
tint = scheme.onBackground,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
@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.background
|
||||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
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.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -31,13 +27,13 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
import hu.bbara.purefin.core.image.ArtworkKind
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
|
||||||
internal val MediaDetailHorizontalPadding = 48.dp
|
internal val MediaDetailHorizontalPadding = 48.dp
|
||||||
private val MediaDetailHeaderTopPadding = 104.dp
|
private val MediaDetailHeaderTopPadding = 6.dp
|
||||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
private val MediaDetailHeaderBottomPadding = 4.dp
|
||||||
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||||
|
|
||||||
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||||
@@ -61,19 +57,13 @@ internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
|||||||
internal fun TvMediaDetailScaffold(
|
internal fun TvMediaDetailScaffold(
|
||||||
resetScrollKey: Any,
|
resetScrollKey: Any,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
bodyContent: @Composable (Modifier) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(resetScrollKey) {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||||
LazyColumn(
|
Column(
|
||||||
state = listState,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.background(scheme.background)
|
||||||
@@ -87,7 +77,7 @@ internal fun TvMediaDetailScaffold(
|
|||||||
internal fun TvMediaDetailBodyBox(
|
internal fun TvMediaDetailBodyBox(
|
||||||
backgroundImageUrl: String,
|
backgroundImageUrl: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
heightFraction: Float = 0.48f,
|
heightFraction: Float = 0.24f,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -99,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = bodyHeight)
|
.heightIn(min = bodyHeight)
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.matchParentSize()) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
) {
|
||||||
PurefinAsyncImage(
|
PurefinAsyncImage(
|
||||||
model = backgroundImageUrl,
|
model = backgroundImageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxHeight()
|
|
||||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
|
||||||
.align(Alignment.TopEnd),
|
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to scheme.background,
|
||||||
|
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||||
|
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||||
|
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colorStops = arrayOf(
|
||||||
|
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||||
|
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||||
|
1.0f to scheme.background
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.horizontalGradient(
|
|
||||||
colorStops = arrayOf(
|
|
||||||
0.0f to scheme.background,
|
|
||||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
|
||||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
|
||||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colorStops = arrayOf(
|
|
||||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
|
||||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
|
||||||
1.0f to scheme.background
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -151,8 +143,8 @@ internal fun TvMediaDetailBodyBox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
||||||
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
val primaryImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
|
||||||
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
|
||||||
return backdropImageUrl.ifBlank { primaryImageUrl }
|
return backdropImageUrl.ifBlank { primaryImageUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +175,11 @@ internal fun MediaDetailOverviewSection(
|
|||||||
title = "Overview",
|
title = "Overview",
|
||||||
titleColor = scheme.onBackground,
|
titleColor = scheme.onBackground,
|
||||||
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||||
titleFontSize = 22.sp,
|
titleFontSize = 15.sp,
|
||||||
bodyFontSize = 16.sp,
|
bodyFontSize = 11.sp,
|
||||||
bodyLineHeight = 24.sp,
|
bodyLineHeight = 14.sp,
|
||||||
titleSpacing = 14.dp,
|
titleSpacing = 2.dp,
|
||||||
collapsedLines = 5,
|
collapsedLines = 1
|
||||||
collapseInitially = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -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.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||||
|
|
||||||
internal data class MediaDetailsTopBarShortcut(
|
internal data class MediaDetailsTopBarShortcut(
|
||||||
val label: String,
|
val label: String,
|
||||||
@@ -53,6 +54,7 @@ internal fun MediaDetailsTopBar(
|
|||||||
backFocusRequester: FocusRequester? = null,
|
backFocusRequester: FocusRequester? = null,
|
||||||
downFocusRequester: FocusRequester? = null
|
downFocusRequester: FocusRequester? = null
|
||||||
) {
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
val downModifier = if (downFocusRequester != null) {
|
val downModifier = if (downFocusRequester != null) {
|
||||||
Modifier.focusProperties { down = downFocusRequester }
|
Modifier.focusProperties { down = downFocusRequester }
|
||||||
} else {
|
} else {
|
||||||
@@ -77,7 +79,9 @@ internal fun MediaDetailsTopBar(
|
|||||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = backModifier.then(downModifier)
|
modifier = backModifier.then(downModifier),
|
||||||
|
focusedScale = 1.1f,
|
||||||
|
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||||
)
|
)
|
||||||
if (shortcut != null) {
|
if (shortcut != null) {
|
||||||
GhostTextButton(
|
GhostTextButton(
|
||||||
@@ -92,13 +96,17 @@ internal fun MediaDetailsTopBar(
|
|||||||
icon = Icons.Outlined.Cast,
|
icon = Icons.Outlined.Cast,
|
||||||
contentDescription = "Cast",
|
contentDescription = "Cast",
|
||||||
onClick = onCastClick,
|
onClick = onCastClick,
|
||||||
modifier = downModifier
|
modifier = downModifier,
|
||||||
|
focusedScale = 1.1f,
|
||||||
|
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||||
)
|
)
|
||||||
GhostIconButton(
|
GhostIconButton(
|
||||||
icon = Icons.Outlined.MoreVert,
|
icon = Icons.Outlined.MoreVert,
|
||||||
contentDescription = "More",
|
contentDescription = "More",
|
||||||
onClick = onMoreClick,
|
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.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -20,13 +17,14 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.core.model.Episode
|
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"
|
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvEpisodeHeroSection(
|
internal fun TvEpisodeHeroSection(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
@@ -41,63 +39,59 @@ internal fun TvEpisodeHeroSection(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
.widthIn(max = 560.dp)
|
||||||
) {
|
) {
|
||||||
if (!seriesTitle.isNullOrBlank()) {
|
if (!seriesTitle.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = seriesTitle,
|
text = seriesTitle,
|
||||||
color = scheme.primary,
|
color = scheme.primary,
|
||||||
fontSize = 18.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = episode.title,
|
text = episode.title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 42.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = 48.sp,
|
lineHeight = 28.sp,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Episode ${episode.index}",
|
text = "Episode ${episode.index}",
|
||||||
color = mutedStrong,
|
color = mutedStrong,
|
||||||
fontSize = 18.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
FlowRow(
|
MediaMetadataFlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
highlightedItem = episode.format
|
||||||
) {
|
)
|
||||||
MediaMetaChip(text = episode.releaseDate)
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
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))
|
|
||||||
MediaResumeButton(
|
MediaResumeButton(
|
||||||
text = episode.playButtonText(),
|
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
progress = mediaPlaybackProgress(episode.progress),
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||||
.focusRequester(playFocusRequester)
|
.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)
|
@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.foundation.gestures.BringIntoViewSpec
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
internal val TvHomeFocusedItemTopOffset = 56.dp
|
internal val TvHomeFocusedItemTopOffset = 48.dp
|
||||||
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
||||||
|
|
||||||
private const val TvHomeRowPivotParentFraction = 0.3f
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -28,237 +31,188 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.common.ui.PosterCard
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
import hu.bbara.purefin.ui.common.card.PosterCardContent
|
||||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import java.util.UUID
|
||||||
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
import kotlin.math.roundToInt
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
|
||||||
import org.jellyfin.sdk.model.UUID
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
|
private const val TvHomeMediaCardScale = 0.9f
|
||||||
|
private val TvHomeSectionsThumbShape = RoundedCornerShape((20f * TvHomeMediaCardScale).dp)
|
||||||
private val TvHomeSectionsHorizontalPadding = 32.dp
|
private val TvHomeSectionsHorizontalPadding = 32.dp
|
||||||
private val TvHomeSectionsRowSpacing = 18.dp
|
private val TvHomeSectionsRowSpacing = 18.dp
|
||||||
private val TvHomeLandscapeCardWidth = 248.dp
|
private val TvHomeLandscapeCardWidth = (248f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomePosterCardWidth = 136.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 TvHomeSectionRowTagPrefix = "tv-home-section-row-"
|
||||||
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
|
|
||||||
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
||||||
|
|
||||||
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
||||||
"${TvHomeSectionRowTagPrefix}library-$libraryId"
|
"${TvHomeSectionRowTagPrefix}library-$libraryId"
|
||||||
|
|
||||||
|
internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
|
||||||
|
"${TvHomeSectionRowTagPrefix}library-item-$libraryId"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvContinueWatchingSection(
|
fun TvContinueWatchingSection(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
|
||||||
firstItemTestTag: String? = null,
|
|
||||||
rowTestTag: String? = null,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = "Continue Watching",
|
title = "Continue Watching",
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TvHomeSectionRow(
|
TvHomeSectionRow(
|
||||||
|
items = items,
|
||||||
|
onFocused = onFocusedItem,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
rowTestTag = rowTestTag
|
) { item, onFocused, focusModifier ->
|
||||||
) {
|
TvHomeLandscapeCard(
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
title = item.primaryText,
|
||||||
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
supporting = item.secondaryText,
|
||||||
TvHomeLandscapeCard(
|
imageUrl = item.primaryImageUrl,
|
||||||
title = item.primaryText,
|
progress = item.progress ?: 0f,
|
||||||
supporting = item.secondaryText,
|
imageModifier = focusModifier,
|
||||||
imageUrl = when (item.type) {
|
onFocusedItem = {
|
||||||
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
onFocused(item)
|
||||||
prefixImageUrl = item.movie?.imageUrlPrefix,
|
},
|
||||||
imageType = ImageType.PRIMARY
|
onClick = { onMediaSelected(item) }
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
|
||||||
prefixImageUrl = item.episode?.imageUrlPrefix,
|
|
||||||
imageType = ImageType.PRIMARY
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
progress = progressFraction,
|
|
||||||
imageModifier = Modifier
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemTestTag != null) {
|
|
||||||
Modifier.testTag(firstItemTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onFocusedItem = { onFocusedItem(item) },
|
|
||||||
onClick = {
|
|
||||||
when (item.type) {
|
|
||||||
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
|
||||||
BaseItemKind.EPISODE -> {
|
|
||||||
val episode = item.episode!!
|
|
||||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvNextUpSection(
|
fun TvNextUpSection(
|
||||||
items: List<NextUpItem>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
|
||||||
firstItemTestTag: String? = null,
|
|
||||||
rowTestTag: String? = null,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = "Next Up",
|
title = "Next Up",
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TvHomeSectionRow(
|
TvHomeSectionRow(
|
||||||
|
items = items,
|
||||||
|
onFocused = onFocusedItem,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
rowTestTag = rowTestTag
|
) { item, onFocused, focusModifier ->
|
||||||
) {
|
TvHomeLandscapeCard(
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
title = item.primaryText,
|
||||||
TvHomeLandscapeCard(
|
supporting = item.secondaryText,
|
||||||
title = item.primaryText,
|
imageUrl = item.primaryImageUrl,
|
||||||
supporting = item.secondaryText,
|
imageModifier = focusModifier,
|
||||||
imageUrl = item.imageUrl,
|
onFocusedItem = { onFocused(item) },
|
||||||
imageModifier = Modifier
|
onClick = { onMediaSelected(item) }
|
||||||
.then(
|
)
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemTestTag != null) {
|
|
||||||
Modifier.testTag(firstItemTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onFocusedItem = { onFocusedItem(item) },
|
|
||||||
onClick = {
|
|
||||||
val episode = item.episode
|
|
||||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvLibraryPosterSection(
|
fun TvLibraryPosterSection(
|
||||||
title: String,
|
title: String,
|
||||||
items: List<PosterItem>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (FocusableItem) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
|
||||||
firstItemTestTag: String? = null,
|
|
||||||
rowTestTag: String? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
TvSectionHeader(
|
TvSectionHeader(
|
||||||
title = title,
|
title = title,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TvHomeSectionRow(
|
TvHomeSectionRow(
|
||||||
|
items = items,
|
||||||
|
onFocused = onFocusedItem,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
rowTestTag = rowTestTag
|
) { item, onFocused, focusModifier ->
|
||||||
) {
|
PosterCardContent(
|
||||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
model = item,
|
||||||
PosterCard(
|
onClick = { onMediaSelected(item) },
|
||||||
item = item,
|
onFocused = { onFocused(item) },
|
||||||
posterWidth = TvHomePosterCardWidth,
|
posterWidth = TvHomePosterCardWidth,
|
||||||
showSecondaryText = true,
|
showSecondaryText = true,
|
||||||
indicatorSize = 24,
|
indicatorSize = TvHomePosterIndicatorSize,
|
||||||
indicatorPadding = 6.dp,
|
indicatorPadding = TvHomePosterIndicatorPadding,
|
||||||
imageModifier = Modifier
|
imageModifier = focusModifier,
|
||||||
.then(
|
focusedScale = 1.07f,
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
focusedBorderWidth = 2.dp
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
)
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemTestTag != null) {
|
|
||||||
Modifier.testTag(firstItemTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onFocusedItem = onFocusedItem,
|
|
||||||
onMovieSelected = onMovieSelected,
|
|
||||||
onSeriesSelected = onSeriesSelected,
|
|
||||||
onEpisodeSelected = onEpisodeSelected
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvHomeSectionRow(
|
private fun TvHomeSectionRow(
|
||||||
|
items: List<MediaUiModel>,
|
||||||
|
onFocused: (MediaUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
rowTestTag: String? = null,
|
childComponent: @Composable (
|
||||||
content: LazyListScope.() -> Unit,
|
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) {
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(
|
.focusRestorer(rowScopedFocusRequester)
|
||||||
if (rowTestTag != null) {
|
.focusGroup(),
|
||||||
Modifier.testTag(rowTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
|
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||||
content = content
|
) {
|
||||||
)
|
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
|
val scheme = MaterialTheme.colorScheme
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
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
|
val cardWidth = TvHomeLandscapeCardWidth
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -316,7 +273,7 @@ private fun TvHomeLandscapeCard(
|
|||||||
.aspectRatio(16f / 9f)
|
.aspectRatio(16f / 9f)
|
||||||
.clip(TvHomeSectionsThumbShape)
|
.clip(TvHomeSectionsThumbShape)
|
||||||
.border(
|
.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),
|
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
|
||||||
shape = TvHomeSectionsThumbShape
|
shape = TvHomeSectionsThumbShape
|
||||||
)
|
)
|
||||||
@@ -355,21 +312,29 @@ private fun TvHomeLandscapeCard(
|
|||||||
foregroundColor = scheme.primary,
|
foregroundColor = scheme.primary,
|
||||||
backgroundColor = scheme.surfaceVariant,
|
backgroundColor = scheme.surfaceVariant,
|
||||||
contentPadding = PaddingValues(0.dp),
|
contentPadding = PaddingValues(0.dp),
|
||||||
barHeight = 6.dp,
|
barHeight = TvHomeLandscapeProgressBarHeight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
.padding(
|
||||||
|
horizontal = TvHomeLandscapeProgressBarPadding,
|
||||||
|
vertical = TvHomeLandscapeProgressBarPadding
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(TvHomeLandscapeMetadataSpacing),
|
||||||
modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp)
|
modifier = Modifier.padding(
|
||||||
|
top = TvHomeLandscapeMetadataTopPadding,
|
||||||
|
start = TvHomeLandscapeMetadataHorizontalPadding,
|
||||||
|
end = TvHomeLandscapeMetadataHorizontalPadding
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 15.sp,
|
fontSize = TvHomeLandscapeTitleFontSize,
|
||||||
|
lineHeight = TvHomeLandscapeTitleFontSize,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -378,7 +343,8 @@ private fun TvHomeLandscapeCard(
|
|||||||
Text(
|
Text(
|
||||||
text = supporting,
|
text = supporting,
|
||||||
color = scheme.onSurfaceVariant,
|
color = scheme.onSurfaceVariant,
|
||||||
fontSize = 11.sp,
|
fontSize = TvHomeLandscapeSupportingFontSize,
|
||||||
|
lineHeight = TvHomeLandscapeSupportingFontSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.semantics.selected
|
import androidx.compose.ui.semantics.selected
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.DrawerState
|
||||||
import androidx.tv.material3.DrawerValue
|
import androidx.tv.material3.DrawerValue
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.NavigationDrawer
|
import androidx.tv.material3.NavigationDrawer
|
||||||
import androidx.tv.material3.NavigationDrawerItem
|
import androidx.tv.material3.NavigationDrawerItem
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
|
||||||
import androidx.tv.material3.darkColorScheme
|
import androidx.tv.material3.darkColorScheme
|
||||||
|
import androidx.tv.material3.rememberDrawerState
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.tv.R
|
import hu.bbara.purefin.tv.R
|
||||||
|
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||||
|
|
||||||
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
|
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
|
||||||
internal const val TvDrawerTitleTag = "tv-drawer-title"
|
internal const val TvDrawerTitleTag = "tv-drawer-title"
|
||||||
|
|
||||||
private val TvDrawerCollapsedWidth = 92.dp
|
|
||||||
private val TvDrawerExpandedWidth = 280.dp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvNavigationDrawer(
|
fun TvNavigationDrawer(
|
||||||
destinations: List<TvDrawerDestinationItem>,
|
destinations: List<TvDrawerDestinationItem>,
|
||||||
selectedDestination: TvDrawerDestination,
|
selectedDestination: Route,
|
||||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
onDestinationSelected: (Route) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
ProvideTvDrawerTheme {
|
ProvideTvDrawerTheme {
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
|
||||||
NavigationDrawer(
|
NavigationDrawer(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
TvNavigationDrawerRail(
|
TvNavigationDrawerRail(
|
||||||
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
|
drawerState = drawerState,
|
||||||
destinations = destinations,
|
destinations = destinations,
|
||||||
selectedDestination = selectedDestination,
|
selectedDestination = selectedDestination,
|
||||||
onDestinationSelected = onDestinationSelected
|
onDestinationSelected = onDestinationSelected
|
||||||
@@ -122,6 +130,8 @@ fun TvDrawerHeader(
|
|||||||
AnimatedVisibility(visible = expanded) {
|
AnimatedVisibility(visible = expanded) {
|
||||||
Text(
|
Text(
|
||||||
text = "Purefin",
|
text = "Purefin",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 16.sp,
|
||||||
modifier = Modifier.testTag(TvDrawerTitleTag)
|
modifier = Modifier.testTag(TvDrawerTitleTag)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -130,60 +140,59 @@ fun TvDrawerHeader(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||||
drawerValue: DrawerValue,
|
drawerState: DrawerState,
|
||||||
destinations: List<TvDrawerDestinationItem>,
|
destinations: List<TvDrawerDestinationItem>,
|
||||||
selectedDestination: TvDrawerDestination,
|
selectedDestination: Route,
|
||||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
onDestinationSelected: (Route) -> Unit,
|
||||||
) {
|
) {
|
||||||
val expanded = drawerValue == DrawerValue.Open
|
val expanded = drawerState.currentValue == DrawerValue.Open
|
||||||
val drawerWidth = animateDpAsState(
|
val scheme = MaterialTheme.colorScheme
|
||||||
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
|
var hasDrawerFocus by remember { mutableStateOf(false) }
|
||||||
label = "tv-drawer-width"
|
|
||||||
)
|
|
||||||
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(drawerWidth.value)
|
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(scheme.surface.copy(alpha = 0.96f))
|
.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(
|
TvDrawerHeader(
|
||||||
modifier = Modifier
|
expanded = expanded,
|
||||||
.fillMaxSize()
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
)
|
||||||
.selectableGroup(),
|
destinations.forEachIndexed { index, destination ->
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
val isSelected = destination.destination == selectedDestination
|
||||||
) {
|
NavigationDrawerItem(
|
||||||
TvDrawerHeader(
|
selected = isSelected,
|
||||||
expanded = expanded,
|
onClick = {
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
onDestinationSelected(destination.destination)
|
||||||
)
|
if (isSelected) {
|
||||||
destinations.forEachIndexed { index, destination ->
|
drawerState.setValue(DrawerValue.Closed)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
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.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Collections
|
|
||||||
import androidx.compose.material.icons.outlined.Movie
|
import androidx.compose.material.icons.outlined.Movie
|
||||||
import androidx.compose.material.icons.outlined.Tv
|
import androidx.compose.material.icons.outlined.Tv
|
||||||
import androidx.compose.material3.Icon
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
|
|
||||||
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
||||||
|
|
||||||
@@ -53,8 +52,8 @@ private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvLibrariesOverviewScreen(
|
fun TvLibrariesOverviewScreen(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryUiModel>,
|
||||||
onLibrarySelected: (LibraryItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -118,7 +117,7 @@ fun TvLibrariesOverviewScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvLibraryOverviewCard(
|
private fun TvLibraryOverviewCard(
|
||||||
item: LibraryItem,
|
item: LibraryUiModel,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -161,9 +160,8 @@ private fun TvLibraryOverviewCard(
|
|||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = when (item.type) {
|
imageVector = when (item.type) {
|
||||||
CollectionType.MOVIES -> Icons.Outlined.Movie
|
LibraryKind.MOVIES -> Icons.Outlined.Movie
|
||||||
CollectionType.TVSHOWS -> Icons.Outlined.Tv
|
LibraryKind.SERIES -> Icons.Outlined.Tv
|
||||||
else -> Icons.Outlined.Collections
|
|
||||||
},
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = scheme.onSurfaceVariant,
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.common.ui.PosterCard
|
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
|
||||||
import hu.bbara.purefin.common.ui.components.PurefinIconButton
|
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||||
import hu.bbara.purefin.core.data.navigation.LibraryDto
|
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
|
|
||||||
import org.jellyfin.sdk.model.UUID
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvLibraryScreen(
|
fun TvLibraryScreen(
|
||||||
library: LibraryDto,
|
library: LibraryDto,
|
||||||
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
viewModel: LibraryViewModel = hiltViewModel(),
|
viewModel: LibraryViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -39,21 +35,19 @@ fun TvLibraryScreen(
|
|||||||
viewModel.selectLibrary(libraryId = library.id)
|
viewModel.selectLibrary(libraryId = library.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
val libraryItems = viewModel.contents.collectAsState()
|
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TvLibraryTopBar(
|
TvLibraryTopBar(
|
||||||
title = library.name,
|
title = library.name
|
||||||
onBack = viewModel::onBack
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
TvLibraryContent(
|
TvLibraryContent(
|
||||||
libraryItems = libraryItems.value,
|
libraryItems = libraryItems.value,
|
||||||
onMovieSelected = viewModel::onMovieSelected,
|
onMediaSelected = onMediaSelected,
|
||||||
onSeriesSelected = viewModel::onSeriesSelected,
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,35 +55,23 @@ fun TvLibraryScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvLibraryTopBar(
|
private fun TvLibraryTopBar(
|
||||||
title: String,
|
title: String
|
||||||
onBack: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvLibraryContent(
|
fun TvLibraryContent(
|
||||||
libraryItems: List<PosterItem>,
|
libraryItems: List<MediaUiModel>,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
@@ -100,12 +82,12 @@ fun TvLibraryContent(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.background)
|
modifier = Modifier.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
items(libraryItems) { item ->
|
items(libraryItems, key = { item -> item.id }) { item ->
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
onMovieSelected = onMovieSelected,
|
onMovieSelected = { onMediaSelected(item) },
|
||||||
onSeriesSelected = onSeriesSelected,
|
onSeriesSelected = { onMediaSelected(item) },
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
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"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
@@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Forward30
|
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
import androidx.compose.material.icons.outlined.PlayArrow
|
import androidx.compose.material.icons.outlined.PlayArrow
|
||||||
import androidx.compose.material.icons.outlined.Replay10
|
|
||||||
import androidx.compose.material.icons.outlined.SkipNext
|
import androidx.compose.material.icons.outlined.SkipNext
|
||||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -64,7 +61,7 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onOpenAudioPanel: () -> Unit,
|
onOpenAudioPanel: () -> Unit,
|
||||||
@@ -109,7 +106,7 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onPlayPause = onPlayPause,
|
onPlayPause = onPlayPause,
|
||||||
onSeek = onSeek,
|
onSeek = onSeek,
|
||||||
onSeekRelative = onSeekRelative,
|
onSeekRelative = onSeekRelative,
|
||||||
onSeekLiveEdge = onSeekLiveEdge,
|
onSkipSegment = onSkipSegment,
|
||||||
onNext = onNext,
|
onNext = onNext,
|
||||||
onPrevious = onPrevious,
|
onPrevious = onPrevious,
|
||||||
onOpenAudioPanel = onOpenAudioPanel,
|
onOpenAudioPanel = onOpenAudioPanel,
|
||||||
@@ -171,7 +168,7 @@ private fun TvPlayerBottomSection(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onOpenAudioPanel: () -> Unit,
|
onOpenAudioPanel: () -> Unit,
|
||||||
@@ -185,7 +182,6 @@ private fun TvPlayerBottomSection(
|
|||||||
subtitlesButtonEnabled: Boolean,
|
subtitlesButtonEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val playlistFocusRequester = remember { FocusRequester() }
|
val playlistFocusRequester = remember { FocusRequester() }
|
||||||
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
||||||
|
|
||||||
@@ -208,42 +204,11 @@ private fun TvPlayerBottomSection(
|
|||||||
.testTag(TvPlayerPlaylistStateTag)
|
.testTag(TvPlayerPlaylistStateTag)
|
||||||
.semantics { stateDescription = playlistExpandState }
|
.semantics { stateDescription = playlistExpandState }
|
||||||
) {
|
) {
|
||||||
Row(
|
TvPlayerTimeRow(
|
||||||
modifier = Modifier
|
positionMs = uiState.positionMs,
|
||||||
.fillMaxWidth()
|
durationMs = uiState.durationMs,
|
||||||
.padding(horizontal = 4.dp),
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = formatTime(uiState.positionMs),
|
|
||||||
color = scheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
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(
|
TvPlayerSeekBar(
|
||||||
positionMs = uiState.positionMs,
|
positionMs = uiState.positionMs,
|
||||||
durationMs = uiState.durationMs,
|
durationMs = uiState.durationMs,
|
||||||
@@ -274,42 +239,28 @@ private fun TvPlayerBottomSection(
|
|||||||
icon = Icons.Outlined.SkipPrevious,
|
icon = Icons.Outlined.SkipPrevious,
|
||||||
contentDescription = "Previous",
|
contentDescription = "Previous",
|
||||||
onClick = onPrevious,
|
onClick = onPrevious,
|
||||||
size = 64,
|
size = 56,
|
||||||
modifier = expandPlaylistModifier
|
|
||||||
)
|
|
||||||
TvIconButton(
|
|
||||||
icon = Icons.Outlined.Replay10,
|
|
||||||
contentDescription = "Seek backward 10 seconds",
|
|
||||||
onClick = { onSeekRelative(-10_000) },
|
|
||||||
size = 64,
|
|
||||||
modifier = expandPlaylistModifier
|
modifier = expandPlaylistModifier
|
||||||
)
|
)
|
||||||
TvIconButton(
|
TvIconButton(
|
||||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||||
onClick = onPlayPause,
|
onClick = onPlayPause,
|
||||||
size = 72,
|
size = 64,
|
||||||
modifier = expandPlaylistModifier
|
modifier = expandPlaylistModifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.testTag(TvPlayerPlayPauseButtonTag)
|
.testTag(TvPlayerPlayPauseButtonTag)
|
||||||
)
|
)
|
||||||
TvIconButton(
|
|
||||||
icon = Icons.Outlined.Forward30,
|
|
||||||
contentDescription = "Seek forward 30 seconds",
|
|
||||||
onClick = { onSeekRelative(30_000) },
|
|
||||||
size = 64,
|
|
||||||
modifier = expandPlaylistModifier
|
|
||||||
)
|
|
||||||
TvIconButton(
|
TvIconButton(
|
||||||
icon = Icons.Outlined.SkipNext,
|
icon = Icons.Outlined.SkipNext,
|
||||||
contentDescription = "Next",
|
contentDescription = "Next",
|
||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
size = 64,
|
size = 56,
|
||||||
modifier = expandPlaylistModifier
|
modifier = expandPlaylistModifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.align(Alignment.CenterEnd),
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -335,6 +286,22 @@ private fun TvPlayerBottomSection(
|
|||||||
.testTag(TvPlayerSubtitlesButtonTag)
|
.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(
|
AnimatedVisibility(
|
||||||
visible = isPlaylistExpanded,
|
visible = isPlaylistExpanded,
|
||||||
@@ -355,6 +322,32 @@ private fun TvPlayerBottomSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TvPlayerTimeRow(
|
||||||
|
positionMs: Long,
|
||||||
|
durationMs: Long,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatTime(positionMs),
|
||||||
|
color = scheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatTime(durationMs),
|
||||||
|
color = scheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleExpandPlaylistKey(
|
private fun handleExpandPlaylistKey(
|
||||||
event: androidx.compose.ui.input.key.KeyEvent,
|
event: androidx.compose.ui.input.key.KeyEvent,
|
||||||
onExpand: () -> Unit
|
onExpand: () -> Unit
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -7,29 +9,37 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.withFrameNanos
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
@@ -41,9 +51,9 @@ import androidx.compose.ui.platform.testTag
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
|
||||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
|
||||||
import coil3.compose.AsyncImage
|
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 TvPlayerPlaylistRowTag = "tv_player_playlist_row"
|
||||||
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
|
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
|
||||||
@@ -82,75 +92,56 @@ internal fun TvPlayerQueuePanel(
|
|||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = scheme.surface.copy(alpha = 0.94f)
|
color = scheme.surface.copy(alpha = 0.94f)
|
||||||
) {
|
) {
|
||||||
Column(
|
if (uiState.queue.isEmpty()) {
|
||||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp),
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
text = "Add something to the queue to browse it here.",
|
||||||
) {
|
color = scheme.onSurfaceVariant,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
style = MaterialTheme.typography.bodyMedium
|
||||||
Text(
|
)
|
||||||
text = "Playlist",
|
} else {
|
||||||
color = scheme.onSurface,
|
LazyRow(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
modifier = Modifier
|
||||||
fontWeight = FontWeight.Bold
|
.fillMaxWidth()
|
||||||
)
|
.testTag(TvPlayerPlaylistRowTag),
|
||||||
Text(
|
state = listState,
|
||||||
text = queueCountLabel,
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
color = scheme.onSurfaceVariant,
|
contentPadding = PaddingValues(16.dp)
|
||||||
style = MaterialTheme.typography.bodyMedium
|
) {
|
||||||
)
|
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
||||||
}
|
val isEntryItem = index == entryIndex
|
||||||
|
TvQueueRowCard(
|
||||||
if (uiState.queue.isEmpty()) {
|
item = item,
|
||||||
Text(
|
isCurrent = item.isCurrent,
|
||||||
text = "Add something to the queue to browse it here.",
|
isFirst = index == 0,
|
||||||
color = scheme.onSurfaceVariant,
|
isLast = index == uiState.queue.lastIndex,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
onClick = { onSelect(item.id) },
|
||||||
)
|
onReturnToControls = onReturnToControls,
|
||||||
} else {
|
modifier = Modifier
|
||||||
LazyRow(
|
.width(228.dp)
|
||||||
modifier = Modifier
|
.then(
|
||||||
.fillMaxWidth()
|
if (isEntryItem) {
|
||||||
.testTag(TvPlayerPlaylistRowTag),
|
Modifier
|
||||||
state = listState,
|
.focusRequester(firstItemFocusRequester)
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
.testTag(TvPlayerPlaylistCurrentItemTag)
|
||||||
contentPadding = PaddingValues(end = 4.dp)
|
} else {
|
||||||
) {
|
Modifier
|
||||||
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
}
|
||||||
val isEntryItem = index == entryIndex
|
)
|
||||||
TvQueueRowCard(
|
.then(
|
||||||
item = item,
|
if (index == 0 && !isEntryItem) {
|
||||||
isCurrent = item.isCurrent,
|
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
||||||
isFirst = index == 0,
|
} else {
|
||||||
isLast = index == uiState.queue.lastIndex,
|
Modifier
|
||||||
onClick = { onSelect(item.id) },
|
}
|
||||||
onReturnToControls = onReturnToControls,
|
)
|
||||||
modifier = Modifier
|
.then(
|
||||||
.width(228.dp)
|
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
||||||
.then(
|
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
||||||
if (isEntryItem) {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
.focusRequester(firstItemFocusRequester)
|
}
|
||||||
.testTag(TvPlayerPlaylistCurrentItemTag)
|
)
|
||||||
} else {
|
)
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && !isEntryItem) {
|
|
||||||
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
|
||||||
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +150,7 @@ internal fun TvPlayerQueuePanel(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvQueueRowCard(
|
private fun TvQueueRowCard(
|
||||||
item: QueueItemUi,
|
item: PlaylistElementUiModel,
|
||||||
isCurrent: Boolean,
|
isCurrent: Boolean,
|
||||||
isFirst: Boolean,
|
isFirst: Boolean,
|
||||||
isLast: Boolean,
|
isLast: Boolean,
|
||||||
@@ -169,25 +160,49 @@ private fun TvQueueRowCard(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
val borderColor = when {
|
val scale by animateFloatAsState(
|
||||||
isFocused -> scheme.primary
|
targetValue = if (isFocused) 1.06f else 1f,
|
||||||
isCurrent -> scheme.primary.copy(alpha = 0.55f)
|
label = "queueCardScale"
|
||||||
else -> scheme.outlineVariant.copy(alpha = 0.35f)
|
)
|
||||||
}
|
val borderColor by animateColorAsState(
|
||||||
val backgroundColor = when {
|
targetValue = when {
|
||||||
isFocused -> scheme.primary.copy(alpha = 0.18f)
|
isFocused -> scheme.primary
|
||||||
isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f)
|
isCurrent -> scheme.tertiary
|
||||||
else -> scheme.surfaceVariant.copy(alpha = 0.62f)
|
else -> scheme.outlineVariant.copy(alpha = 0.5f)
|
||||||
|
},
|
||||||
|
label = "queueCardBorder"
|
||||||
|
)
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.primaryContainer.copy(alpha = 0.96f)
|
||||||
|
isCurrent -> scheme.tertiaryContainer.copy(alpha = 0.86f)
|
||||||
|
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||||
|
},
|
||||||
|
label = "queueCardBackground"
|
||||||
|
)
|
||||||
|
val contentColor = when {
|
||||||
|
isFocused -> scheme.onPrimaryContainer
|
||||||
|
isCurrent -> scheme.onTertiaryContainer
|
||||||
|
else -> scheme.onSurface
|
||||||
}
|
}
|
||||||
|
val shape = RoundedCornerShape(18.dp)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.border(
|
.border(
|
||||||
width = if (isFocused) 2.dp else 1.dp,
|
width = when {
|
||||||
|
isFocused -> 3.dp
|
||||||
|
isCurrent -> 2.dp
|
||||||
|
else -> 1.dp
|
||||||
|
},
|
||||||
color = borderColor,
|
color = borderColor,
|
||||||
shape = RoundedCornerShape(18.dp)
|
shape = shape
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(shape)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
@@ -207,46 +222,34 @@ private fun TvQueueRowCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(12.dp),
|
.padding(4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
TvQueueArtwork(
|
TvQueueArtwork(
|
||||||
artworkUrl = item.artworkUrl,
|
artworkUrl = item.artworkUrl,
|
||||||
|
isCurrent = isCurrent,
|
||||||
|
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
|
||||||
|
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Text(
|
||||||
if (isCurrent) {
|
text = item.title,
|
||||||
Text(
|
color = contentColor,
|
||||||
text = "Current",
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = scheme.primary,
|
fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
maxLines = 1,
|
||||||
fontWeight = FontWeight.SemiBold
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
}
|
)
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
color = scheme.onSurface,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
item.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
color = scheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvQueueArtwork(
|
private fun TvQueueArtwork(
|
||||||
artworkUrl: String?,
|
artworkUrl: String?,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
badgeColor: Color,
|
||||||
|
badgeContentColor: Color,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -277,5 +280,30 @@ private fun TvQueueArtwork(
|
|||||||
.background(scheme.surfaceContainerHigh)
|
.background(scheme.surfaceContainerHigh)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(badgeColor)
|
||||||
|
.padding(horizontal = 9.dp, vertical = 5.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = badgeContentColor,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Now playing",
|
||||||
|
color = badgeContentColor,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -18,15 +16,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.core.player.model.MarkerType
|
|
||||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -43,13 +38,8 @@ internal fun TvPlayerSeekBar(
|
|||||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||||
val position = positionMs.coerceIn(0, safeDuration)
|
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) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -90,52 +80,17 @@ internal fun TvPlayerSeekBar(
|
|||||||
.focusable(),
|
.focusable(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Canvas(
|
PlayerSeekBarTrack(
|
||||||
modifier = Modifier
|
positionMs = position,
|
||||||
.fillMaxSize()
|
durationMs = safeDuration,
|
||||||
.padding(horizontal = 2.dp, vertical = 10.dp)
|
bufferedMs = bufferedMs,
|
||||||
) {
|
chapterMarkers = chapterMarkers,
|
||||||
val trackHeight = if (isFocused) 6f else 4f
|
adMarkers = adMarkers,
|
||||||
val trackTop = size.height / 2 - trackHeight / 2
|
modifier = Modifier.fillMaxSize(),
|
||||||
drawRect(
|
isFocused = isFocused,
|
||||||
color = scheme.onSurface.copy(alpha = 0.2f),
|
thumbRadius = 7.dp,
|
||||||
size = Size(width = size.width, height = trackHeight),
|
focusedThumbRadius = 9.dp,
|
||||||
topLeft = Offset(0f, trackTop)
|
focusedThumbHaloRadiusDelta = 4.dp
|
||||||
)
|
)
|
||||||
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,25 +1,30 @@
|
|||||||
package hu.bbara.purefin.tv.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
import androidx.compose.material.icons.outlined.ClosedCaption
|
import androidx.compose.material.icons.outlined.ClosedCaption
|
||||||
import androidx.compose.material.icons.outlined.HighQuality
|
import androidx.compose.material.icons.outlined.HighQuality
|
||||||
import androidx.compose.material.icons.outlined.Language
|
import androidx.compose.material.icons.outlined.Language
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -211,23 +216,42 @@ private fun TvTrackOptionRow(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.primaryContainer
|
||||||
|
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
|
||||||
|
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||||
|
},
|
||||||
|
label = "trackOptionBackground"
|
||||||
|
)
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.onPrimaryContainer
|
||||||
|
selected -> scheme.onSecondaryContainer
|
||||||
|
else -> scheme.onSurface
|
||||||
|
},
|
||||||
|
label = "trackOptionContent"
|
||||||
|
)
|
||||||
|
val borderColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.primary
|
||||||
|
selected -> scheme.secondary
|
||||||
|
else -> scheme.outlineVariant.copy(alpha = 0.45f)
|
||||||
|
},
|
||||||
|
label = "trackOptionBorder"
|
||||||
|
)
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(
|
.border(
|
||||||
if (isFocused) {
|
width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
|
||||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
color = borderColor,
|
||||||
} else {
|
shape = shape
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(
|
|
||||||
if (isFocused) scheme.primary.copy(alpha = 0.3f)
|
|
||||||
else if (selected) scheme.primary.copy(alpha = 0.15f)
|
|
||||||
else scheme.surfaceVariant.copy(alpha = 0.6f)
|
|
||||||
)
|
)
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) {
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
@@ -244,11 +268,25 @@ private fun TvTrackOptionRow(
|
|||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = label,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = scheme.onSurface,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
) {
|
||||||
)
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = contentColor,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<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"/>
|
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
|
||||||
</adaptive-icon>
|
</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.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import hu.bbara.purefin.core.image.ArtworkKind
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class TvFocusedItemHeroTest {
|
class TvFocusedItemHeroTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun movieItem_mapsProgressMetadataAndBackdrop() {
|
fun movieItem_mapsMetadataAndBackdrop() {
|
||||||
val movie = sampleMovie(progress = 42.0)
|
val movie = sampleMovie(progress = 42.0)
|
||||||
val item = ContinueWatchingItem(
|
val item = ContinueWatchingItem(
|
||||||
type = BaseItemKind.MOVIE,
|
type = MediaKind.MOVIE,
|
||||||
movie = movie
|
movie = movie
|
||||||
)
|
)
|
||||||
|
|
||||||
val hero = item.toTvFocusedHeroModel()
|
val hero = item.toTvFocusedHeroModel()
|
||||||
|
|
||||||
assertEquals("Continue watching", hero.eyebrowText)
|
|
||||||
assertEquals("Blade Runner 2049", hero.title)
|
assertEquals("Blade Runner 2049", hero.title)
|
||||||
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
|
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
|
||||||
assertEquals(0.42f, hero.progressFraction!!, 0.0001f)
|
|
||||||
assertEquals("42%", hero.progressLabel)
|
|
||||||
assertNull(hero.watchedText)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP),
|
ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.BACKDROP),
|
||||||
hero.backdropImageUrl
|
hero.backdropImageUrl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +45,7 @@ class TvFocusedItemHeroTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun episodeItem_prefersWatchedStateOverProgress() {
|
fun episodeItem_mapsEpisodeMetadata() {
|
||||||
val item = NextUpItem(
|
val item = NextUpItem(
|
||||||
episode = sampleEpisode(
|
episode = sampleEpisode(
|
||||||
progress = 96.0,
|
progress = 96.0,
|
||||||
@@ -60,29 +55,21 @@ class TvFocusedItemHeroTest {
|
|||||||
|
|
||||||
val hero = item.toTvFocusedHeroModel()
|
val hero = item.toTvFocusedHeroModel()
|
||||||
|
|
||||||
assertEquals("Next up", hero.eyebrowText)
|
|
||||||
assertEquals("The Very Pulse of the Machine", hero.title)
|
assertEquals("The Very Pulse of the Machine", hero.title)
|
||||||
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
|
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
|
||||||
assertEquals("Watched", hero.watchedText)
|
|
||||||
assertNull(hero.progressFraction)
|
|
||||||
assertNull(hero.progressLabel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() {
|
fun seriesItem_usesSeasonAndUnwatchedMetadata() {
|
||||||
val item = PosterItem(
|
val item = PosterItem(
|
||||||
type = BaseItemKind.SERIES,
|
type = MediaKind.SERIES,
|
||||||
series = sampleSeries()
|
series = sampleSeries()
|
||||||
)
|
)
|
||||||
|
|
||||||
val hero = item.toTvFocusedHeroModel()
|
val hero = item.toTvFocusedHeroModel()
|
||||||
|
|
||||||
assertEquals("Series", hero.eyebrowText)
|
|
||||||
assertEquals("Love, Death & Robots", hero.title)
|
assertEquals("Love, Death & Robots", hero.title)
|
||||||
assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText)
|
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 {
|
private fun sampleMovie(progress: Double? = 42.0): Movie {
|
||||||
@@ -108,7 +95,9 @@ class TvFocusedItemHeroTest {
|
|||||||
return Episode(
|
return Episode(
|
||||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||||
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||||
|
seriesName = "Love, Death & Robots",
|
||||||
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||||
|
seasonIndex = 3,
|
||||||
index = 3,
|
index = 3,
|
||||||
title = "The Very Pulse of the Machine",
|
title = "The Very Pulse of the Machine",
|
||||||
synopsis = "An astronaut faces a strange world alone.",
|
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.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -104,19 +104,20 @@ class TvHomeHeroStateTest {
|
|||||||
assertEquals(continueWatching, registry.firstAvailableItem)
|
assertEquals(continueWatching, registry.firstAvailableItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem {
|
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryUiModel {
|
||||||
return LibraryItem(
|
return LibraryUiModel(
|
||||||
id = id,
|
id = id,
|
||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = CollectionType.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = isEmpty
|
isEmpty = isEmpty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem {
|
private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem {
|
||||||
return ContinueWatchingItem(
|
return ContinueWatchingItem(
|
||||||
type = BaseItemKind.MOVIE,
|
type = MediaKind.MOVIE,
|
||||||
movie = sampleMovie(id = id, title = title)
|
movie = sampleMovie(id = id, title = title)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,7 +127,9 @@ class TvHomeHeroStateTest {
|
|||||||
episode = Episode(
|
episode = Episode(
|
||||||
id = UUID.fromString(id),
|
id = UUID.fromString(id),
|
||||||
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||||
|
seriesName = "Sample Series",
|
||||||
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||||
|
seasonIndex = 1,
|
||||||
index = 1,
|
index = 1,
|
||||||
title = "Next Episode",
|
title = "Next Episode",
|
||||||
synopsis = "Episode synopsis",
|
synopsis = "Episode synopsis",
|
||||||
@@ -144,7 +147,7 @@ class TvHomeHeroStateTest {
|
|||||||
|
|
||||||
private fun posterItem(id: String, libraryId: UUID): PosterItem {
|
private fun posterItem(id: String, libraryId: UUID): PosterItem {
|
||||||
return PosterItem(
|
return PosterItem(
|
||||||
type = BaseItemKind.MOVIE,
|
type = MediaKind.MOVIE,
|
||||||
movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId)
|
movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -11,24 +11,29 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin"
|
namespace = "hu.bbara.purefin"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "hu.bbara.purefin"
|
applicationId = "hu.bbara.purefin"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1000002
|
|
||||||
versionName = "0.1"
|
versionName = "0.1"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app-debug/update.json"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
// Enables code-related app optimization.
|
// Enables code-related app optimization.
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
|
||||||
// Enables resource shrinking.
|
// Enables resource shrinking.
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
|
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app/update.json"
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
)
|
)
|
||||||
@@ -40,6 +45,20 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withBuildType("debug")) { variant ->
|
||||||
|
variant.outputs.forEach { output ->
|
||||||
|
output.versionCode.set(providers.gradleProperty("purefinDebugVersionCode").map { it.toInt() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onVariants(selector().withBuildType("release")) { variant ->
|
||||||
|
variant.outputs.forEach { output ->
|
||||||
|
output.versionCode.set(providers.gradleProperty("purefinReleaseVersionCode").map { it.toInt() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,13 +69,14 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:model"))
|
implementation(project(":data"))
|
||||||
implementation(project(":core:data"))
|
implementation(project(":core"))
|
||||||
implementation(project(":feature:download"))
|
//TODO: temporary hack fix it
|
||||||
implementation(project(":core:player"))
|
implementation(project(":core-model"))
|
||||||
implementation(project(":feature:shared"))
|
implementation(project(":core-ui"))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -83,8 +103,6 @@ dependencies {
|
|||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
implementation(libs.androidx.navigation3.runtime)
|
implementation(libs.androidx.navigation3.runtime)
|
||||||
implementation(libs.androidx.navigation3.ui)
|
implementation(libs.androidx.navigation3.ui)
|
||||||
implementation(libs.androidx.room.ktx)
|
|
||||||
ksp(libs.androidx.room.compiler)
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||