mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
314 Commits
2433ececac
...
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@
|
|||||||
local.properties
|
local.properties
|
||||||
.codex/
|
.codex/
|
||||||
openspec
|
openspec
|
||||||
|
.opencode
|
||||||
|
*.patch
|
||||||
|
|||||||
77
AGENTS.md
Normal file
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
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"
|
||||||
@@ -53,9 +55,13 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":data"))
|
implementation(project(":data"))
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
|
implementation(project(":core-model"))
|
||||||
implementation(project(":core-ui"))
|
implementation(project(":core-ui"))
|
||||||
|
implementation(libs.androidx.compose.animation)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -82,6 +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.firebase.crashlytics)
|
||||||
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
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"
|
||||||
|
}
|
||||||
@@ -105,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.",
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.LibraryKind
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -385,13 +385,14 @@ 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 = LibraryKind.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = false
|
isEmpty = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -425,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.",
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import hu.bbara.purefin.model.LibraryKind
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -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 = LibraryKind.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 = LibraryKind.SERIES,
|
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,977 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.screen.player.components
|
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
|
||||||
import androidx.compose.ui.semantics.SemanticsProperties
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
|
||||||
import androidx.compose.ui.test.SemanticsMatcher
|
|
||||||
import androidx.compose.ui.test.assert
|
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import hu.bbara.purefin.player.model.PlayerUiState
|
|
||||||
import hu.bbara.purefin.player.model.QueueItemUi
|
|
||||||
import hu.bbara.purefin.player.model.TrackOption
|
|
||||||
import hu.bbara.purefin.player.model.TrackType
|
|
||||||
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
|
||||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
|
|
||||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
|
|
||||||
import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
|
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
private const val TvPlayerRootTag = "tv_player_root"
|
|
||||||
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
|
||||||
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
|
||||||
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
class TvPlayerControlsOverlayTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
requestFocus = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.Focused,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"collapsed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"expanded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"collapsed"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"expanded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initiallyExpanded = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.assertIsFocused()
|
|
||||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Current").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
OverlayHost(
|
|
||||||
uiState = samplePlayerState().copy(
|
|
||||||
queue = listOf(
|
|
||||||
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 hiddenControls_leftSeeksWithoutShowingControls() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"-10000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hiddenControls_rightSeeksWithoutShowingControls() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState(),
|
|
||||||
initialControlsVisible = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionRight)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
|
||||||
.assert(
|
|
||||||
SemanticsMatcher.expectValue(
|
|
||||||
SemanticsProperties.StateDescription,
|
|
||||||
"10000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.Back)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun leftOnTrackPanel_keepsFocusInsidePanel() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(uiState = samplePlayerState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
|
|
||||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionCenter)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun trackButtonsWithoutOptions_areDisabled() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TrackPanelHost(
|
|
||||||
uiState = samplePlayerState().copy(
|
|
||||||
audioTracks = emptyList(),
|
|
||||||
textTracks = emptyList(),
|
|
||||||
qualityTracks = emptyList(),
|
|
||||||
selectedAudioTrackId = null,
|
|
||||||
selectedTextTrackId = null,
|
|
||||||
selectedQualityTrackId = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
|
|
||||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun OverlayHost(
|
|
||||||
uiState: PlayerUiState,
|
|
||||||
initiallyExpanded: Boolean = false,
|
|
||||||
requestFocus: Boolean = false
|
|
||||||
) {
|
|
||||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
LaunchedEffect(requestFocus) {
|
|
||||||
if (requestFocus) {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
|
||||||
TvPlayerControlsOverlay(
|
|
||||||
uiState = uiState,
|
|
||||||
focusRequester = focusRequester,
|
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
|
||||||
onPlayPause = {},
|
|
||||||
onSeek = { _ -> },
|
|
||||||
onSeekRelative = { _ -> },
|
|
||||||
onSeekLiveEdge = {},
|
|
||||||
onNext = {},
|
|
||||||
onPrevious = {},
|
|
||||||
onOpenAudioPanel = {},
|
|
||||||
onOpenSubtitlesPanel = {},
|
|
||||||
onOpenQualityPanel = {},
|
|
||||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
|
||||||
onCollapsePlaylist = { isPlaylistExpanded = false },
|
|
||||||
onSelectQueueItem = { _ -> },
|
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrackPanelHost(
|
|
||||||
uiState: PlayerUiState,
|
|
||||||
initialControlsVisible: Boolean = true
|
|
||||||
) {
|
|
||||||
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
|
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
|
||||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
|
||||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
|
||||||
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
|
||||||
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
|
||||||
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
|
|
||||||
val rootFocusRequester = remember { FocusRequester() }
|
|
||||||
val controlsFocusRequester = remember { FocusRequester() }
|
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
|
||||||
val closeTrackPanel: () -> Unit = {
|
|
||||||
trackPanelType?.let { panelType ->
|
|
||||||
pendingTrackButtonFocus = panelType
|
|
||||||
trackPanelType = null
|
|
||||||
controlsVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
pauseWithoutControlsCount += 1
|
|
||||||
stopFeedbackVisible = true
|
|
||||||
stopFeedbackRequestId += 1
|
|
||||||
}
|
|
||||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
resumeWithoutControlsCount += 1
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
|
||||||
lastHiddenSeekDeltaMs = deltaMs
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
|
||||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
|
||||||
if (trackPanelType != null) return@LaunchedEffect
|
|
||||||
when (pendingFocus) {
|
|
||||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
|
||||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
|
||||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
pendingTrackButtonFocus = null
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(stopFeedbackRequestId) {
|
|
||||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
|
||||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, trackPanelType) {
|
|
||||||
if (controlsVisible || trackPanelType != null) {
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
} else {
|
|
||||||
rootFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 960.dp, height = 540.dp)
|
|
||||||
.focusRequester(rootFocusRequester)
|
|
||||||
.focusable()
|
|
||||||
.testTag(TvPlayerRootTag)
|
|
||||||
.onPreviewKeyEvent { event ->
|
|
||||||
handleTvPlayerRootKeyEvent(
|
|
||||||
event = event,
|
|
||||||
isPlaying = uiState.isPlaying,
|
|
||||||
controlsVisible = controlsVisible,
|
|
||||||
isPlaylistExpanded = false,
|
|
||||||
trackPanelType = trackPanelType,
|
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
|
||||||
onCollapsePlaylist = { controlsVisible = true },
|
|
||||||
onHideControls = { controlsVisible = false },
|
|
||||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
|
||||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
|
||||||
onSeekRelative = seekWithoutShowingControls,
|
|
||||||
onShowControls = { controlsVisible = true },
|
|
||||||
onTogglePlayPause = { controlsVisible = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (controlsVisible || trackPanelType != null) {
|
|
||||||
TvPlayerControlsOverlay(
|
|
||||||
uiState = uiState,
|
|
||||||
focusRequester = controlsFocusRequester,
|
|
||||||
isPlaylistExpanded = false,
|
|
||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
|
||||||
onPlayPause = {},
|
|
||||||
onSeek = { _ -> },
|
|
||||||
onSeekRelative = { _ -> },
|
|
||||||
onSeekLiveEdge = {},
|
|
||||||
onNext = {},
|
|
||||||
onPrevious = {},
|
|
||||||
onOpenAudioPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.AUDIO
|
|
||||||
},
|
|
||||||
onOpenSubtitlesPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.SUBTITLES
|
|
||||||
},
|
|
||||||
onOpenQualityPanel = {
|
|
||||||
controlsVisible = true
|
|
||||||
trackPanelType = TvTrackPanelType.QUALITY
|
|
||||||
},
|
|
||||||
onExpandPlaylist = {},
|
|
||||||
onCollapsePlaylist = {},
|
|
||||||
onSelectQueueItem = { _ -> },
|
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
|
||||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = stopFeedbackVisible,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
) {
|
|
||||||
TvPlayerHiddenStopFeedback()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(TvPlayerHiddenPauseCountTag)
|
|
||||||
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(TvPlayerHiddenResumeCountTag)
|
|
||||||
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(TvPlayerHiddenSeekDeltaTag)
|
|
||||||
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
trackPanelType?.let { panelType ->
|
|
||||||
TvTrackSelectionPanel(
|
|
||||||
panelType = panelType,
|
|
||||||
uiState = uiState,
|
|
||||||
onSelect = { closeTrackPanel() },
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
|
||||||
isPlaying = true,
|
|
||||||
title = "Sample Player",
|
|
||||||
subtitle = "Season 1",
|
|
||||||
durationMs = 3_600_000,
|
|
||||||
positionMs = 120_000,
|
|
||||||
bufferedMs = 240_000,
|
|
||||||
queue = listOf(
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
@@ -17,7 +17,7 @@ import hu.bbara.purefin.model.Season
|
|||||||
import hu.bbara.purefin.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.SeriesFirstSeasonTabTag
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
|
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
|
||||||
@@ -30,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(
|
||||||
@@ -44,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()
|
||||||
@@ -66,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()
|
||||||
@@ -87,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)
|
||||||
}
|
}
|
||||||
@@ -122,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.",
|
||||||
@@ -138,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"
|
||||||
@@ -23,7 +25,11 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/purefin_logo_round"
|
android:roundIcon="@mipmap/purefin_logo_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Purefin">
|
android:theme="@style/Theme.Purefin">
|
||||||
|
<meta-data
|
||||||
|
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||||
|
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
|
||||||
<activity
|
<activity
|
||||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -34,6 +40,9 @@
|
|||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||||
import dagger.multibindings.IntoSet
|
import dagger.multibindings.IntoSet
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.navigation.tvEpisodeSection
|
import hu.bbara.purefin.navigation.tvEpisodeSection
|
||||||
import hu.bbara.purefin.navigation.tvHomeSection
|
import hu.bbara.purefin.navigation.tvHomeSection
|
||||||
import hu.bbara.purefin.navigation.tvLibrarySection
|
import hu.bbara.purefin.navigation.tvLibrarySection
|
||||||
import hu.bbara.purefin.navigation.tvLoginSection
|
import hu.bbara.purefin.navigation.tvLoginSection
|
||||||
import hu.bbara.purefin.navigation.tvMovieSection
|
import hu.bbara.purefin.navigation.tvMovieSection
|
||||||
import hu.bbara.purefin.navigation.tvPlayerSection
|
import hu.bbara.purefin.navigation.tvPlayerSection
|
||||||
|
import hu.bbara.purefin.navigation.tvSettingsSection
|
||||||
import hu.bbara.purefin.navigation.tvSeriesSection
|
import hu.bbara.purefin.navigation.tvSeriesSection
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -60,4 +61,10 @@ object TvNavigationModule {
|
|||||||
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||||
tvLibrarySection()
|
tvLibrarySection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IntoSet
|
||||||
|
@Provides
|
||||||
|
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||||
|
tvSettingsSection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import dagger.Module
|
|||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import hu.bbara.purefin.data.PlaybackProfileFamily
|
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package hu.bbara.purefin.navigation
|
|||||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
|
|
||||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package hu.bbara.purefin.navigation
|
package hu.bbara.purefin.navigation
|
||||||
|
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
|
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||||
|
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
|
||||||
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
|
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
|
||||||
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
|
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
|
||||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||||
import hu.bbara.purefin.ui.screen.TvAppScreen
|
import hu.bbara.purefin.ui.screen.TvAppScreen
|
||||||
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||||
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
|
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
|
||||||
|
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||||
|
|
||||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||||
entry<Route.Home> {
|
entry<Route.Home> {
|
||||||
@@ -17,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
|
|||||||
|
|
||||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||||
entry<Route.LoginRoute> {
|
entry<Route.LoginRoute> {
|
||||||
LoginScreen()
|
TvLoginScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,11 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
|
|||||||
|
|
||||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
||||||
entry<Route.EpisodeRoute> { route ->
|
entry<Route.EpisodeRoute> { route ->
|
||||||
TvEpisodeScreen(episode = route.item)
|
TvSeriesScreen(
|
||||||
|
series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
|
||||||
|
focusedSeasonId = route.item.seasonId,
|
||||||
|
focusedEpisodeId = route.item.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +59,16 @@ fun EntryProviderScope<Route>.tvPlayerSection() {
|
|||||||
|
|
||||||
fun EntryProviderScope<Route>.tvLibrarySection() {
|
fun EntryProviderScope<Route>.tvLibrarySection() {
|
||||||
entry<Route.LibraryRoute> { route ->
|
entry<Route.LibraryRoute> { route ->
|
||||||
TvLibraryScreen(library = route.library)
|
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,15 +43,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import hu.bbara.purefin.data.SessionBootstrapper
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.navigation.NavigationCommand
|
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
||||||
import hu.bbara.purefin.navigation.NavigationManager
|
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||||
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
|
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import hu.bbara.purefin.ui.theme.backgroundDark
|
import hu.bbara.purefin.ui.theme.backgroundDark
|
||||||
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
|
|||||||
fun MainApp(
|
fun MainApp(
|
||||||
userSessionRepository: UserSessionRepository,
|
userSessionRepository: UserSessionRepository,
|
||||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||||
navigationManager: NavigationManager
|
navigationManager: NavigationManager,
|
||||||
|
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
var sessionLoaded by remember { mutableStateOf(false) }
|
var sessionLoaded by remember { mutableStateOf(false) }
|
||||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
var showExitDialog by remember { mutableStateOf(false) }
|
||||||
|
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
userSessionRepository.isLoggedIn.collect {
|
userSessionRepository.isLoggedIn.collect {
|
||||||
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionLoaded) {
|
LaunchedEffect(updateViewModel) {
|
||||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
updateViewModel.checkForUpdatesOnAppOpen()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoggedIn) {
|
LaunchedEffect(updateViewModel, context) {
|
||||||
|
updateViewModel.snackbarMessages.collect { message ->
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionLoaded) {
|
||||||
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||||
|
} else if (isLoggedIn) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||||
val appEntryProvider = entryProvider {
|
val appEntryProvider = entryProvider {
|
||||||
@@ -161,7 +185,13 @@ class TvActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(navigationManager, backStack) {
|
LaunchedEffect(navigationManager, backStack) {
|
||||||
navigationManager.commands.collect { command ->
|
navigationManager.commands.collect { command ->
|
||||||
when (command) {
|
when (command) {
|
||||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
NavigationCommand.Pop -> {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
} else {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||||
is NavigationCommand.ReplaceAll -> {
|
is NavigationCommand.ReplaceAll -> {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
@@ -171,13 +201,23 @@ class TvActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size == 1) {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNavigationManager provides navigationManager,
|
LocalNavigationManager provides navigationManager,
|
||||||
LocalNavigationBackStack provides backStack.toList()
|
LocalNavigationBackStack provides backStack.toList()
|
||||||
) {
|
) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = { navigationManager.pop() },
|
onBack = {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
navigationManager.pop()
|
||||||
|
} else {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
fadeIn(
|
fadeIn(
|
||||||
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LoginScreen()
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
TvLoginScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
availableUpdate?.let { update ->
|
||||||
|
TvUpdateAvailableDialog(
|
||||||
|
update = update,
|
||||||
|
onAccept = updateViewModel::acceptUpdate,
|
||||||
|
onDecline = updateViewModel::declineUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExitDialog) {
|
||||||
|
ExitAppDialog(
|
||||||
|
onCloseConfirmed = { finishAndRemoveTask() },
|
||||||
|
onDismiss = { showExitDialog = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TvUpdateAvailableDialog(
|
||||||
|
update: AppUpdateInfo,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit
|
||||||
|
) {
|
||||||
|
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||||
|
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||||
|
val updateText = if (releaseNotes == null) {
|
||||||
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||||
|
} else {
|
||||||
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDecline,
|
||||||
|
title = { Text("Update available") },
|
||||||
|
text = { Text(updateText) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onAccept) {
|
||||||
|
Text("Install")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDecline) {
|
||||||
|
Text("Not now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,73 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.common.button
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,10 +29,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.ui.model.SeriesUiModel
|
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -43,7 +44,6 @@ fun PosterCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageModifier: Modifier = Modifier,
|
imageModifier: Modifier = Modifier,
|
||||||
posterWidth: Dp = 144.dp,
|
posterWidth: Dp = 144.dp,
|
||||||
contentScale: Float = 1f,
|
|
||||||
showSecondaryText: Boolean = false,
|
showSecondaryText: Boolean = false,
|
||||||
indicatorSize: Int = 28,
|
indicatorSize: Int = 28,
|
||||||
indicatorPadding: Dp = 8.dp,
|
indicatorPadding: Dp = 8.dp,
|
||||||
@@ -65,7 +65,6 @@ fun PosterCard(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
imageModifier = imageModifier,
|
imageModifier = imageModifier,
|
||||||
posterWidth = posterWidth,
|
posterWidth = posterWidth,
|
||||||
contentScale = contentScale,
|
|
||||||
showSecondaryText = showSecondaryText,
|
showSecondaryText = showSecondaryText,
|
||||||
indicatorSize = indicatorSize,
|
indicatorSize = indicatorSize,
|
||||||
indicatorPadding = indicatorPadding,
|
indicatorPadding = indicatorPadding,
|
||||||
@@ -82,11 +81,10 @@ fun PosterCardContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageModifier: Modifier = Modifier,
|
imageModifier: Modifier = Modifier,
|
||||||
posterWidth: Dp = 144.dp,
|
posterWidth: Dp = 144.dp,
|
||||||
contentScale: Float = 1f,
|
|
||||||
showSecondaryText: Boolean = false,
|
showSecondaryText: Boolean = false,
|
||||||
indicatorSize: Int = 28,
|
indicatorSize: Int = 28,
|
||||||
indicatorPadding: Dp = 8.dp,
|
indicatorPadding: Dp = 8.dp,
|
||||||
onFocused: () -> Unit = {},
|
onFocused: () -> Unit,
|
||||||
focusedScale: Float = 1f,
|
focusedScale: Float = 1f,
|
||||||
focusedBorderWidth: Dp = 1.dp,
|
focusedBorderWidth: Dp = 1.dp,
|
||||||
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
|
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
@@ -137,10 +135,17 @@ fun PosterCardContent(
|
|||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(indicatorPadding),
|
.padding(indicatorPadding),
|
||||||
watched = model.watched,
|
watched = model.watched,
|
||||||
started = (model.progress ?: 0f) > 0f
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> Unit
|
is SeriesUiModel -> {
|
||||||
|
UnwatchedEpisodeBadge(
|
||||||
|
unwatchedCount = model.unwatchedEpisodeCount,
|
||||||
|
size = 26,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(indicatorPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
@@ -155,6 +160,7 @@ fun PosterCardContent(
|
|||||||
text = model.primaryText,
|
text = model.primaryText,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -167,6 +173,7 @@ fun PosterCardContent(
|
|||||||
text = text,
|
text = text,
|
||||||
color = scheme.onSurfaceVariant,
|
color = scheme.onSurfaceVariant,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 11.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.core.image.ArtworkKind
|
||||||
|
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -81,8 +81,6 @@ internal fun MediaDetailsTopBar(
|
|||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = backModifier.then(downModifier),
|
modifier = backModifier.then(downModifier),
|
||||||
focusedScale = 1.1f,
|
focusedScale = 1.1f,
|
||||||
focusedBorderWidth = 2.5.dp,
|
|
||||||
focusedBorderColor = scheme.primary,
|
|
||||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||||
)
|
)
|
||||||
if (shortcut != null) {
|
if (shortcut != null) {
|
||||||
@@ -100,8 +98,6 @@ internal fun MediaDetailsTopBar(
|
|||||||
onClick = onCastClick,
|
onClick = onCastClick,
|
||||||
modifier = downModifier,
|
modifier = downModifier,
|
||||||
focusedScale = 1.1f,
|
focusedScale = 1.1f,
|
||||||
focusedBorderWidth = 2.5.dp,
|
|
||||||
focusedBorderColor = scheme.primary,
|
|
||||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||||
)
|
)
|
||||||
GhostIconButton(
|
GhostIconButton(
|
||||||
@@ -110,8 +106,6 @@ internal fun MediaDetailsTopBar(
|
|||||||
onClick = onMoreClick,
|
onClick = onMoreClick,
|
||||||
modifier = downModifier,
|
modifier = downModifier,
|
||||||
focusedScale = 1.1f,
|
focusedScale = 1.1f,
|
||||||
focusedBorderWidth = 2.5.dp,
|
|
||||||
focusedBorderColor = scheme.primary,
|
|
||||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,74 @@
|
|||||||
package hu.bbara.purefin.ui.screen
|
package hu.bbara.purefin.ui.screen
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Collections
|
|
||||||
import androidx.compose.material.icons.outlined.Home
|
import androidx.compose.material.icons.outlined.Home
|
||||||
import androidx.compose.material.icons.outlined.Movie
|
import androidx.compose.material.icons.outlined.Movie
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.Tv
|
import androidx.compose.material.icons.outlined.Tv
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
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.model.LibraryKind
|
||||||
import hu.bbara.purefin.feature.browse.home.AppViewModel
|
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestination
|
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
|
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
|
||||||
import hu.bbara.purefin.ui.screen.libraries.TvLibrariesOverviewScreen
|
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
|
||||||
|
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvAppScreen(
|
fun TvAppScreen(
|
||||||
viewModel: AppViewModel = hiltViewModel(),
|
viewModel: AppViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val libraries by viewModel.libraries.collectAsState()
|
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
|
||||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
|
||||||
val nextUp by viewModel.nextUp.collectAsState()
|
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
val latestLibraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
|
||||||
|
val navigationManager = LocalNavigationManager.current
|
||||||
|
|
||||||
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) }
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||||
|
val selectedDestination = backStack.lastOrNull() ?: Route.Home
|
||||||
|
|
||||||
val destinations = remember(libraries, selectedDestination) {
|
val destinations = remember(libraries) {
|
||||||
listOf(
|
listOf(
|
||||||
TvDrawerDestinationItem(
|
TvDrawerDestinationItem(
|
||||||
destination = TvDrawerDestination.HOME,
|
destination = Route.Home,
|
||||||
label = "Home",
|
label = "Home",
|
||||||
icon = Icons.Outlined.Home,
|
icon = Icons.Outlined.Home
|
||||||
selected = selectedDestination == TvDrawerDestination.HOME
|
|
||||||
),
|
|
||||||
TvDrawerDestinationItem(
|
|
||||||
destination = TvDrawerDestination.LIBRARIES,
|
|
||||||
label = "Libraries",
|
|
||||||
icon = when {
|
|
||||||
libraries.any { it.type == LibraryKind.MOVIES } -> Icons.Outlined.Movie
|
|
||||||
libraries.any { it.type == LibraryKind.SERIES } -> Icons.Outlined.Tv
|
|
||||||
else -> Icons.Outlined.Collections
|
|
||||||
},
|
|
||||||
selected = selectedDestination == TvDrawerDestination.LIBRARIES
|
|
||||||
)
|
)
|
||||||
|
) + 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,34 +77,68 @@ fun TvAppScreen(
|
|||||||
onPauseOrDispose { }
|
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(
|
TvNavigationDrawer(
|
||||||
destinations = destinations,
|
destinations = destinations,
|
||||||
selectedDestination = selectedDestination,
|
selectedDestination = selectedDestination,
|
||||||
onDestinationSelected = { destination ->
|
onDestinationSelected = { destination ->
|
||||||
selectedDestination = 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()
|
modifier = modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
when (selectedDestination) {
|
NavDisplay(
|
||||||
TvDrawerDestination.HOME -> {
|
backStack = backStack,
|
||||||
TvHomeScreen(
|
onBack = {
|
||||||
libraries = libraries,
|
if (backStack.size > 1) {
|
||||||
libraryContent = latestLibraryContent,
|
backStack.removeLastOrNull()
|
||||||
continueWatching = continueWatching,
|
} else {
|
||||||
nextUp = nextUp,
|
navigationManager.pop()
|
||||||
onMediaSelected = viewModel::onMediaSelected,
|
}
|
||||||
modifier = Modifier.fillMaxSize()
|
},
|
||||||
)
|
entryDecorators = listOf(
|
||||||
}
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
TvDrawerDestination.LIBRARIES -> {
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
TvLibrariesOverviewScreen(
|
),
|
||||||
libraries = libraries,
|
entryProvider = tvEntryProvider,
|
||||||
onLibrarySelected = { library ->
|
modifier = Modifier.fillMaxSize()
|
||||||
viewModel.onLibrarySelected(library.id, library.name)
|
)
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,27 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
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.MediaDetailOverviewSection
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
|
import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.navigation.EpisodeDto
|
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
|
||||||
import hu.bbara.purefin.navigation.Route
|
|
||||||
import hu.bbara.purefin.model.Episode
|
|
||||||
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvEpisodeScreen(
|
fun TvEpisodeScreen(
|
||||||
@@ -38,15 +37,10 @@ fun TvEpisodeScreen(
|
|||||||
val navigationManager = LocalNavigationManager.current
|
val navigationManager = LocalNavigationManager.current
|
||||||
|
|
||||||
LaunchedEffect(episode) {
|
LaunchedEffect(episode) {
|
||||||
viewModel.selectEpisode(
|
viewModel.selectEpisode(episode)
|
||||||
seriesId = episode.seriesId,
|
|
||||||
seasonId = episode.seasonId,
|
|
||||||
episodeId = episode.id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val episode = viewModel.episode.collectAsState()
|
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||||
val seriesTitle = viewModel.seriesTitle.collectAsState()
|
|
||||||
val selectedEpisode = episode.value
|
val selectedEpisode = episode.value
|
||||||
|
|
||||||
if (selectedEpisode == null) {
|
if (selectedEpisode == null) {
|
||||||
@@ -56,7 +50,7 @@ fun TvEpisodeScreen(
|
|||||||
|
|
||||||
TvEpisodeScreenContent(
|
TvEpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seriesTitle = seriesTitle.value,
|
seriesTitle = selectedEpisode.seriesName,
|
||||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||||
{
|
{
|
||||||
navigationManager.navigate(
|
navigationManager.navigate(
|
||||||
@@ -86,50 +80,43 @@ internal fun TvEpisodeScreenContent(
|
|||||||
resetScrollKey = episode.id,
|
resetScrollKey = episode.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "episode-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
|
||||||
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
episode.imageUrlPrefix,
|
||||||
modifier = it
|
ArtworkKind.PRIMARY
|
||||||
) {
|
),
|
||||||
TvEpisodeHeroSection(
|
modifier = it
|
||||||
episode = episode,
|
) {
|
||||||
seriesTitle = seriesTitle,
|
TvEpisodeHeroSection(
|
||||||
onPlay = onPlay,
|
episode = episode,
|
||||||
playFocusRequester = playFocusRequester,
|
seriesTitle = seriesTitle,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onPlay = onPlay,
|
||||||
)
|
playFocusRequester = playFocusRequester,
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
modifier = Modifier.fillMaxWidth()
|
||||||
}
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
item(key = "episode-overview") {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
MediaDetailOverviewSection(
|
||||||
MediaDetailOverviewSection(
|
synopsis = episode.synopsis,
|
||||||
synopsis = episode.synopsis,
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item(key = "episode-playback") {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
MediaDetailPlaybackSection(
|
||||||
MediaDetailPlaybackSection(
|
audioTrack = "ENG",
|
||||||
audioTrack = "ENG",
|
subtitles = "ENG",
|
||||||
subtitles = "ENG",
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (episode.cast.isNotEmpty()) {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
item(key = "episode-cast") {
|
MediaDetailSectionTitle(text = "Cast")
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
// MediaCastRow(cast = episode.cast)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
MediaCastRow(cast = episode.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,47 +39,47 @@ 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))
|
||||||
MediaMetadataFlowRow(
|
MediaMetadataFlowRow(
|
||||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||||
highlightedItem = episode.format
|
highlightedItem = episode.format
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
MediaResumeButton(
|
MediaResumeButton(
|
||||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||||
progress = mediaPlaybackProgress(episode.progress),
|
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,
|
focusedScale = 1.08f,
|
||||||
@@ -88,7 +88,10 @@ internal fun TvEpisodeHeroSection(
|
|||||||
focusBorderColor = scheme.onBackground,
|
focusBorderColor = scheme.onBackground,
|
||||||
overlayBorderWidth = 2.dp,
|
overlayBorderWidth = 2.dp,
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||||
focusable = true
|
focusable = true,
|
||||||
|
height = 40.dp,
|
||||||
|
textSize = 13.sp,
|
||||||
|
iconSize = 20.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home
|
package hu.bbara.purefin.ui.screen.home
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -10,17 +10,17 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
||||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvHomeScreen(
|
fun TvHomeScreen(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryUiModel>,
|
||||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||||
continueWatching: List<MediaUiModel>,
|
continueWatching: List<MediaUiModel>,
|
||||||
nextUp: List<MediaUiModel>,
|
nextUp: List<MediaUiModel>,
|
||||||
@@ -31,31 +31,33 @@ fun TvHomeScreen(
|
|||||||
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
color = scheme.background
|
||||||
.background(scheme.background)
|
|
||||||
) {
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
TvHomeHeroBackdrop(
|
||||||
modifier = Modifier.fillMaxSize()
|
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
|
||||||
) {
|
|
||||||
TvFocusedItemHero(
|
|
||||||
item = focusedMediaUiModel.value,
|
|
||||||
height = 220.dp
|
|
||||||
)
|
|
||||||
TvHomeContent(
|
|
||||||
libraries = libraries,
|
|
||||||
libraryContent = libraryContent,
|
|
||||||
continueWatching = continueWatching,
|
|
||||||
nextUp = nextUp,
|
|
||||||
onMediaFocused = {
|
|
||||||
focusedMediaUiModel.value = it
|
|
||||||
},
|
|
||||||
onMediaSelected = onMediaSelected,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
TvFocusedItemHero(
|
||||||
|
item = focusedMediaUiModel.value
|
||||||
|
)
|
||||||
|
TvHomeContent(
|
||||||
|
libraries = libraries,
|
||||||
|
libraryContent = libraryContent,
|
||||||
|
continueWatching = continueWatching,
|
||||||
|
nextUp = nextUp,
|
||||||
|
onMediaFocused = {
|
||||||
|
focusedMediaUiModel.value = it
|
||||||
|
},
|
||||||
|
onMediaSelected = onMediaSelected,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,118 +2,136 @@ package hu.bbara.purefin.ui.screen.home.components
|
|||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||||
|
|
||||||
private const val TvHomeHeroAnimationMillis = 180
|
private const val TvHomeHeroAnimationMillis = 180
|
||||||
|
// The billboard backdrop covers the top portion of the home screen so the hero
|
||||||
|
// text sits at the bottom of the backdrop and the content rows begin below it.
|
||||||
|
internal const val TvHomeHeroHeightFraction = 0.45f
|
||||||
|
internal const val TvHomeBackdropOffset = 0.20f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home screen background image plus the darkening gradients that keep the
|
||||||
|
* hero text readable. Drawn behind the home content and sized to the top
|
||||||
|
* [TvHomeHeroHeightFraction] of the screen.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvFocusedItemHero(
|
internal fun TvHomeHeroBackdrop(
|
||||||
item: MediaUiModel,
|
backdropImageUrl: String?,
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
Box(
|
Crossfade(
|
||||||
|
targetState = backdropImageUrl,
|
||||||
|
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||||
|
label = "tv-home-hero-backdrop",
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height)
|
.fillMaxHeight(heightFraction)
|
||||||
.background(scheme.background)
|
.drawWithContent {
|
||||||
) {
|
drawContent()
|
||||||
Crossfade(
|
// Darken the left side so the text block stays readable over any backdrop.
|
||||||
targetState = item.backdropImageUrl,
|
drawRect(
|
||||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
|
||||||
label = "tv-home-hero-background"
|
|
||||||
) { imageUrl ->
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background,
|
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
0.34f to scheme.background.copy(alpha = 0.62f),
|
||||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
0.66f to scheme.background.copy(alpha = 0.22f),
|
||||||
1.0f to scheme.background.copy(alpha = 0.06f)
|
1.0f to scheme.background.copy(alpha = 0.04f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
// Strong bottom fade so the title and metadata are legible.
|
||||||
Box(
|
drawRect(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background.copy(alpha = 0f),
|
0.0f to scheme.background.copy(alpha = 0f),
|
||||||
0.56f to scheme.background.copy(alpha = 0.1f),
|
0.40f to scheme.background.copy(alpha = 0.10f),
|
||||||
|
0.72f to scheme.background.copy(alpha = 0.58f),
|
||||||
1.0f to scheme.background
|
1.0f to scheme.background
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
) { imageUrl ->
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
Crossfade(
|
}
|
||||||
targetState = item,
|
}
|
||||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
|
||||||
label = "tv-home-hero-content"
|
@Composable
|
||||||
) { hero ->
|
internal fun TvFocusedItemHero(
|
||||||
|
item: MediaUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Crossfade(
|
||||||
|
targetState = item,
|
||||||
|
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||||
|
label = "tv-home-hero-content",
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(heightFraction)
|
||||||
|
) { hero ->
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Bottom,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 40.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.Bottom,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 720.dp)
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 40.dp, vertical = 18.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
text = hero.primaryText,
|
||||||
modifier = Modifier.widthIn(max = 720.dp)
|
color = scheme.onBackground,
|
||||||
) {
|
fontSize = 40.sp,
|
||||||
Text(
|
fontWeight = FontWeight.Bold,
|
||||||
text = hero.primaryText,
|
lineHeight = 44.sp,
|
||||||
color = scheme.onBackground,
|
maxLines = 2,
|
||||||
fontSize = 34.sp,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||||
lineHeight = 38.sp,
|
)
|
||||||
maxLines = 1,
|
TvHomeHeroMetadataRow(item = hero)
|
||||||
overflow = TextOverflow.Ellipsis,
|
if (hero.description.isNotBlank()) {
|
||||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = hero.description,
|
text = hero.description,
|
||||||
color = scheme.onSurfaceVariant,
|
color = scheme.onSurfaceVariant,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
lineHeight = 18.sp,
|
lineHeight = 18.sp,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,3 +139,27 @@ internal fun TvFocusedItemHero(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
||||||
|
val hasSecondary = item.secondaryText.isNotBlank()
|
||||||
|
val progress = item.progress
|
||||||
|
val hasProgress = progress != null && progress > 0f && progress < 1f
|
||||||
|
if (!hasSecondary && !hasProgress) return
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (hasSecondary) {
|
||||||
|
TvHomeMetaChip(text = item.secondaryText)
|
||||||
|
}
|
||||||
|
if (hasProgress) {
|
||||||
|
val percent = ((progress ?: 0f) * 100f).roundToInt()
|
||||||
|
TvHomeMetaChip(
|
||||||
|
text = "Resume \u00B7 $percent%",
|
||||||
|
highlighted = true,
|
||||||
|
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 = 42.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
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
package hu.bbara.purefin.ui.screen.home.components
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -20,11 +19,11 @@ 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.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||||
@@ -32,7 +31,7 @@ internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvHomeContent(
|
fun TvHomeContent(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryUiModel>,
|
||||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||||
continueWatching: List<MediaUiModel>,
|
continueWatching: List<MediaUiModel>,
|
||||||
nextUp: List<MediaUiModel>,
|
nextUp: List<MediaUiModel>,
|
||||||
@@ -41,14 +40,10 @@ fun TvHomeContent(
|
|||||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||||
val hasNextUp = nextUp.isNotEmpty()
|
val hasNextUp = nextUp.isNotEmpty()
|
||||||
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
|
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
|
||||||
val firstLibraryWithItemsId = libraries.firstOrNull {
|
|
||||||
libraryContent[it.id].orEmpty().isNotEmpty()
|
|
||||||
}?.id
|
|
||||||
val hasVisibleContent = hasContinueWatching || hasNextUp
|
val hasVisibleContent = hasContinueWatching || hasNextUp
|
||||||
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
|
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
|
||||||
val initialFocusRequester = remember { FocusRequester() }
|
val initialFocusRequester = remember { FocusRequester() }
|
||||||
@@ -67,22 +62,16 @@ fun TvHomeContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.focusRequester(initialFocusRequester),
|
||||||
.testTag(TvHomeContentViewportTag),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
contentPadding = contentPadding
|
contentPadding = contentPadding
|
||||||
) {
|
) {
|
||||||
item(key = "tv-home-top-spacer") {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
if (hasContinueWatching) {
|
if (hasContinueWatching) {
|
||||||
item(key = "tv-home-continue-watching") {
|
item(key = "tv-home-continue-watching") {
|
||||||
TvContinueWatchingSection(
|
TvContinueWatchingSection(
|
||||||
items = continueWatching,
|
items = continueWatching,
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
onMediaSelected = onMediaSelected,
|
onMediaSelected = onMediaSelected,
|
||||||
firstItemFocusRequester = initialFocusRequester,
|
|
||||||
firstItemTestTag = TvHomeInitialFocusTag,
|
|
||||||
rowTestTag = TvHomeContinueWatchingRowTag
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,9 +82,6 @@ fun TvHomeContent(
|
|||||||
items = nextUp,
|
items = nextUp,
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
onMediaSelected = onMediaSelected,
|
onMediaSelected = onMediaSelected,
|
||||||
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
|
|
||||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
|
|
||||||
rowTestTag = TvHomeNextUpRowTag
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +94,6 @@ fun TvHomeContent(
|
|||||||
title = library.name,
|
title = library.name,
|
||||||
items = libraryContent[library.id].orEmpty(),
|
items = libraryContent[library.id].orEmpty(),
|
||||||
onFocusedItem = onMediaFocused,
|
onFocusedItem = onMediaFocused,
|
||||||
firstItemFocusRequester = initialFocusRequester.takeIf {
|
|
||||||
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
|
|
||||||
},
|
|
||||||
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
|
|
||||||
onMediaSelected = onMediaSelected
|
onMediaSelected = onMediaSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
enum class TvDrawerDestination {
|
|
||||||
HOME,
|
|
||||||
LIBRARIES
|
|
||||||
}
|
|
||||||
|
|
||||||
data class TvDrawerDestinationItem(
|
data class TvDrawerDestinationItem(
|
||||||
val destination: TvDrawerDestination,
|
val destination: Route,
|
||||||
val label: String,
|
val label: String,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector
|
||||||
val selected: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,21 +5,24 @@ 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,26 +31,27 @@ 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.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.card.PosterCardContent
|
import hu.bbara.purefin.ui.common.card.PosterCardContent
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -61,15 +65,14 @@ private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
|
|||||||
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeMetadataSpacing = (4f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeMetadataSpacing = (1f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeMetadataTopPadding = (12f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeMetadataTopPadding = (6f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
|
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
|
||||||
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
|
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
|
||||||
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
|
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
|
||||||
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
|
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
|
||||||
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
|
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 =
|
||||||
@@ -81,91 +84,59 @@ internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
|
|||||||
@Composable
|
@Composable
|
||||||
fun TvContinueWatchingSection(
|
fun TvContinueWatchingSection(
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
onMediaSelected: (MediaUiModel) -> 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 = "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,
|
||||||
TvHomeLandscapeCard(
|
supporting = item.secondaryText,
|
||||||
title = item.primaryText,
|
imageUrl = item.primaryImageUrl,
|
||||||
supporting = item.secondaryText,
|
progress = item.progress ?: 0f,
|
||||||
imageUrl = item.primaryImageUrl,
|
imageModifier = focusModifier,
|
||||||
progress = item.progress ?: 0f,
|
onFocusedItem = {
|
||||||
imageModifier = Modifier
|
onFocused(item)
|
||||||
.then(
|
},
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
onClick = { onMediaSelected(item) }
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
)
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemTestTag != null) {
|
|
||||||
Modifier.testTag(firstItemTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onFocusedItem = { onFocusedItem(item) },
|
|
||||||
onClick = { onMediaSelected(item) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvNextUpSection(
|
fun TvNextUpSection(
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
onMediaSelected: (MediaUiModel) -> 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.primaryImageUrl,
|
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 = { onMediaSelected(item) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,74 +144,75 @@ fun TvNextUpSection(
|
|||||||
fun TvLibraryPosterSection(
|
fun TvLibraryPosterSection(
|
||||||
title: String,
|
title: String,
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
onFocusedItem: (MediaUiModel) -> Unit,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
|
||||||
firstItemTestTag: String? = null,
|
|
||||||
rowTestTag: String? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> 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,
|
||||||
PosterCardContent(
|
onClick = { onMediaSelected(item) },
|
||||||
model = item,
|
onFocused = { onFocused(item) },
|
||||||
onClick = { onMediaSelected(item) },
|
posterWidth = TvHomePosterCardWidth,
|
||||||
posterWidth = TvHomePosterCardWidth,
|
showSecondaryText = true,
|
||||||
contentScale = TvHomeMediaCardScale,
|
indicatorSize = TvHomePosterIndicatorSize,
|
||||||
showSecondaryText = true,
|
indicatorPadding = TvHomePosterIndicatorPadding,
|
||||||
indicatorSize = TvHomePosterIndicatorSize,
|
imageModifier = focusModifier,
|
||||||
indicatorPadding = TvHomePosterIndicatorPadding,
|
focusedScale = 1.07f,
|
||||||
imageModifier = Modifier
|
focusedBorderWidth = 2.dp
|
||||||
.then(
|
)
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (index == 0 && firstItemTestTag != null) {
|
|
||||||
Modifier.testTag(firstItemTestTag)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
onFocused = { onFocusedItem(item) },
|
|
||||||
focusedScale = 1.07f,
|
|
||||||
focusedBorderWidth = 2.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,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(
|
||||||
@@ -359,6 +334,7 @@ private fun TvHomeLandscapeCard(
|
|||||||
text = title,
|
text = title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = TvHomeLandscapeTitleFontSize,
|
fontSize = TvHomeLandscapeTitleFontSize,
|
||||||
|
lineHeight = TvHomeLandscapeTitleFontSize,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -368,6 +344,7 @@ private fun TvHomeLandscapeCard(
|
|||||||
text = supporting,
|
text = supporting,
|
||||||
color = scheme.onSurfaceVariant,
|
color = scheme.onSurfaceVariant,
|
||||||
fontSize = TvHomeLandscapeSupportingFontSize,
|
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.ui.screen.home.components
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.model.LibraryKind
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
|
|
||||||
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
||||||
@@ -52,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
|
||||||
@@ -117,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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,34 +4,30 @@ 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.navigation.LibraryDto
|
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||||
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
|
|
||||||
import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
|
||||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||||
import java.util.UUID
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@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,40 +55,23 @@ fun TvLibraryScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvLibraryTopBar(
|
private fun TvLibraryTopBar(
|
||||||
title: String,
|
title: String
|
||||||
onBack: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
Text(
|
||||||
Row(
|
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,
|
|
||||||
focusedScale = 1.1f,
|
|
||||||
focusedBorderWidth = 2.5.dp,
|
|
||||||
focusedBorderColor = scheme.onPrimary,
|
|
||||||
focusedBackgroundColor = scheme.primary
|
|
||||||
)
|
|
||||||
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<MediaUiModel>,
|
libraryItems: List<MediaUiModel>,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
@@ -108,9 +85,9 @@ fun TvLibraryContent(
|
|||||||
items(libraryItems, key = { item -> item.id }) { 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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.screen.login
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import hu.bbara.purefin.feature.login.LoginViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginScreen(
|
|
||||||
viewModel: LoginViewModel = hiltViewModel(),
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val serverUrl by viewModel.url.collectAsState()
|
|
||||||
val username by viewModel.username.collectAsState()
|
|
||||||
val password by viewModel.password.collectAsState()
|
|
||||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
|
||||||
var isLoggingIn by remember { mutableStateOf(false) }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val state = remember(serverUrl, username, password, errorMessage) {
|
|
||||||
LoginContentState(
|
|
||||||
serverUrl = serverUrl,
|
|
||||||
username = username,
|
|
||||||
password = password,
|
|
||||||
errorMessage = errorMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val callbacks = remember(viewModel, coroutineScope) {
|
|
||||||
LoginContentCallbacks(
|
|
||||||
onServerUrlChange = {
|
|
||||||
viewModel.clearError()
|
|
||||||
viewModel.setUrl(it)
|
|
||||||
},
|
|
||||||
onUsernameChange = {
|
|
||||||
viewModel.clearError()
|
|
||||||
viewModel.setUsername(it)
|
|
||||||
},
|
|
||||||
onPasswordChange = {
|
|
||||||
viewModel.clearError()
|
|
||||||
viewModel.setPassword(it)
|
|
||||||
},
|
|
||||||
onConnect = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLoggingIn = true
|
|
||||||
try {
|
|
||||||
viewModel.login()
|
|
||||||
} finally {
|
|
||||||
isLoggingIn = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LoginContent(
|
|
||||||
state = state,
|
|
||||||
callbacks = callbacks,
|
|
||||||
isLoggingIn = isLoggingIn,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,40 +1,38 @@
|
|||||||
package hu.bbara.purefin.ui.screen.movie
|
package hu.bbara.purefin.ui.screen.movie
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import hu.bbara.purefin.core.navigation.MovieDto
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
|
import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
|
||||||
import hu.bbara.purefin.navigation.MovieDto
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.model.Movie
|
|
||||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvMovieScreen(
|
fun TvMovieScreen(
|
||||||
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
|
movie: MovieDto,
|
||||||
|
viewModel: MovieScreenViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(movie.id) {
|
LaunchedEffect(movie) {
|
||||||
viewModel.selectMovie(movie.id)
|
viewModel.selectMovie(movie)
|
||||||
}
|
}
|
||||||
|
|
||||||
val movieItem = viewModel.movie.collectAsState()
|
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (movieItem.value != null) {
|
if (movieItem.value != null) {
|
||||||
TvMovieScreenContent(
|
TvMovieScreenContent(
|
||||||
@@ -64,10 +62,15 @@ internal fun TvMovieScreenContent(
|
|||||||
resetScrollKey = movie.id,
|
resetScrollKey = movie.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "movie-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = it
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvMovieHeroSection(
|
TvMovieHeroSection(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
@@ -75,37 +78,6 @@ internal fun TvMovieScreenContent(
|
|||||||
playFocusRequester = playFocusRequester,
|
playFocusRequester = playFocusRequester,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "movie-overview") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
MediaDetailOverviewSection(
|
|
||||||
synopsis = movie.synopsis,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "movie-playback") {
|
|
||||||
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(key = "movie-cast") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
MediaCastRow(cast = movie.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
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
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,11 +17,11 @@ 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.model.Movie
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||||
|
|
||||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||||
|
|
||||||
@@ -33,33 +33,67 @@ internal fun TvMovieHeroSection(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = movie.title,
|
text = movie.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(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
MediaMetadataFlowRow(
|
MediaMetadataFlowRow(
|
||||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||||
highlightedItem = movie.format
|
highlightedItem = movie.format
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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(
|
MediaResumeButton(
|
||||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||||
progress = mediaPlaybackProgress(movie.progress),
|
progress = mediaPlaybackProgress(movie.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(MoviePlayButtonTag),
|
.testTag(MoviePlayButtonTag),
|
||||||
focusedScale = 1.08f,
|
focusedScale = 1.08f,
|
||||||
@@ -68,7 +102,10 @@ internal fun TvMovieHeroSection(
|
|||||||
focusBorderColor = scheme.onBackground,
|
focusBorderColor = scheme.onBackground,
|
||||||
overlayBorderWidth = 2.dp,
|
overlayBorderWidth = 2.dp,
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||||
focusable = true
|
focusable = true,
|
||||||
|
height = 40.dp,
|
||||||
|
textSize = 13.sp,
|
||||||
|
iconSize = 20.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package hu.bbara.purefin.ui.screen.player
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -12,22 +11,29 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
|
import androidx.compose.material.icons.outlined.SkipNext
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -36,27 +42,45 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker
|
import androidx.media3.ui.SubtitleView
|
||||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||||
|
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||||
|
import hu.bbara.purefin.model.SegmentType
|
||||||
|
import hu.bbara.purefin.ui.common.visual.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.TvPlayerControlsOverlay
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||||
|
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||||
|
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
|
||||||
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
||||||
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
||||||
|
|
||||||
@@ -71,18 +95,42 @@ fun TvPlayerScreen(
|
|||||||
viewModel.loadMedia(mediaId)
|
viewModel.loadMedia(mediaId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val scope = rememberCoroutineScope()
|
||||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var controlsVisible by remember { mutableStateOf(true) }
|
||||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
|
||||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
// This is a hack for timed visibility.
|
||||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
|
||||||
|
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.setControlsAutoHideDelay(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
|
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) {
|
LaunchedEffect(uiState.isPlaying) {
|
||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
@@ -92,95 +140,85 @@ fun TvPlayerScreen(
|
|||||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
fun showControls() {
|
||||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
controlsVisible = true
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
}
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
fun hideControls() {
|
||||||
|
controlsVisible = false
|
||||||
|
}
|
||||||
|
fun hideControlsWithTimeout() {
|
||||||
|
hideControlsJob?.cancel()
|
||||||
|
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
|
||||||
|
hideControlsJob = scope.launch {
|
||||||
|
delay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||||
|
hideControls()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Needed because of composition scheduling
|
||||||
LaunchedEffect(isPlaylistExpanded) {
|
LaunchedEffect(controlsVisible) {
|
||||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
if (controlsVisible) {
|
||||||
}
|
controlsFocusRequester.requestFocus()
|
||||||
|
} else {
|
||||||
LaunchedEffect(trackPanelType) {
|
rootFocusRequester.requestFocus()
|
||||||
viewModel.setControlsAutoHideBlocked(
|
|
||||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
|
||||||
trackPanelType != null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val 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 = {
|
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||||
if (isPlaylistExpanded) {
|
hideControlsWithTimeout()
|
||||||
isPlaylistExpanded = false
|
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val showTvControls: () -> Unit = {
|
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
fun expandPlaylist() {
|
||||||
|
// TODO: focus management for playlist expansion
|
||||||
|
isPlaylistExpanded = true
|
||||||
}
|
}
|
||||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
fun closePlaylist() {
|
||||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
// TODO focus
|
||||||
}
|
isPlaylistExpanded = false
|
||||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
viewModel.pausePlayback()
|
|
||||||
stopFeedbackVisible = true
|
|
||||||
stopFeedbackRequestId += 1
|
|
||||||
}
|
|
||||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
|
||||||
viewModel.resumePlayback()
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
|
||||||
viewModel.seekTo(positionMs)
|
|
||||||
showTvControls()
|
|
||||||
}
|
|
||||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
|
||||||
viewModel.seekBy(deltaMs)
|
|
||||||
showTvControls()
|
|
||||||
}
|
|
||||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
|
||||||
viewModel.seekBy(deltaMs)
|
|
||||||
stopFeedbackVisible = false
|
|
||||||
}
|
|
||||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
|
||||||
viewModel.seekToLiveEdge()
|
|
||||||
showTvControls()
|
|
||||||
}
|
|
||||||
val nextAndShowControls: () -> Unit = {
|
|
||||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
|
||||||
val previousAndShowControls: () -> Unit = {
|
|
||||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
|
||||||
}
|
}
|
||||||
val closeTrackPanel: () -> Unit = {
|
val closeTrackPanel: () -> Unit = {
|
||||||
trackPanelType?.let { panelType ->
|
trackPanelType?.let { panelType ->
|
||||||
pendingTrackButtonFocus = panelType
|
pendingTrackButtonFocus = panelType
|
||||||
trackPanelType = null
|
trackPanelType = null
|
||||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
}
|
||||||
|
}
|
||||||
|
fun handleBack() {
|
||||||
|
when {
|
||||||
|
trackPanelType != null -> closeTrackPanel()
|
||||||
|
isPlaylistExpanded -> closePlaylist()
|
||||||
|
controlsVisible -> hideControls()
|
||||||
|
else -> onBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, controlsAutoHideBlocked) {
|
|
||||||
if (controlsAutoHideBlocked) return@LaunchedEffect
|
val showSkipIntroButton = !controlsVisible
|
||||||
if (controlsVisible) {
|
&& uiState.activeSkippableSegmentEndMs != null
|
||||||
controlsFocusRequester.requestFocus()
|
&& uiState.activeSkippableSegmentType == SegmentType.INTRO
|
||||||
} else {
|
&& !uiState.isEnded
|
||||||
hiddenControlFocusRequester.requestFocus()
|
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) {
|
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||||
if (trackPanelType != null) return@LaunchedEffect
|
if (trackPanelType != null) return@LaunchedEffect
|
||||||
@@ -192,48 +230,44 @@ fun TvPlayerScreen(
|
|||||||
pendingTrackButtonFocus = null
|
pendingTrackButtonFocus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(stopFeedbackRequestId) {
|
val subtitleBottomPaddingFraction =
|
||||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
if (controlsVisible) {
|
||||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||||
stopFeedbackVisible = false
|
} else {
|
||||||
}
|
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||||
|
|
||||||
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(hiddenControlFocusRequester)
|
.focusRequester(rootFocusRequester)
|
||||||
.onPreviewKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
handleTvPlayerRootKeyEvent(
|
if (event.key == Key.Back) {
|
||||||
event = event,
|
if (event.type == KeyEventType.KeyDown) {
|
||||||
isPlaying = uiState.isPlaying,
|
handleBack()
|
||||||
controlsVisible = controlsVisible,
|
}
|
||||||
isPlaylistExpanded = isPlaylistExpanded,
|
true
|
||||||
trackPanelType = trackPanelType,
|
} else {
|
||||||
onCloseTrackPanel = closeTrackPanel,
|
val handled = handleTvPlayerRootKeyEvent(
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
event = event,
|
||||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
controlsVisible = controlsVisible,
|
||||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
onShowControls = ::showControls,
|
||||||
onSeekRelative = seekByWithoutShowingControls,
|
onTogglePlayback = {
|
||||||
onShowControls = showTvControls,
|
resumeStopFeedbackCounter++
|
||||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
viewModel.togglePlayPause()
|
||||||
)
|
},
|
||||||
|
onSeekRelative = {
|
||||||
|
hiddenSeekCounter++
|
||||||
|
viewModel.seekBy(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (event.type == KeyEventType.KeyDown) {
|
||||||
|
hideControlsWithTimeout()
|
||||||
|
}
|
||||||
|
handled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
) {
|
) {
|
||||||
@@ -243,16 +277,22 @@ fun TvPlayerScreen(
|
|||||||
useController = false
|
useController = false
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
player = viewModel.player
|
player = viewModel.player
|
||||||
|
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
|
isFocusable = false
|
||||||
|
isFocusableInTouchMode = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { it.player = viewModel.player },
|
update = {
|
||||||
|
it.player = viewModel.player
|
||||||
|
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
|
visible = controlsVisible,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
@@ -264,20 +304,25 @@ fun TvPlayerScreen(
|
|||||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||||
onPlayPause = togglePlayPauseAndShowControls,
|
onPlayPause = { viewModel.togglePlayPause() },
|
||||||
onSeek = seekAndShowControls,
|
onSeek = { positionMs ->
|
||||||
onSeekRelative = seekByAndShowControls,
|
viewModel.seekTo(positionMs)
|
||||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
},
|
||||||
onNext = nextAndShowControls,
|
onSeekRelative = { deltaMs ->
|
||||||
onPrevious = previousAndShowControls,
|
viewModel.seekBy(deltaMs)
|
||||||
|
},
|
||||||
|
onSkipSegment = {
|
||||||
|
viewModel.skipActiveSegment()
|
||||||
|
},
|
||||||
|
onNext = { viewModel.next() },
|
||||||
|
onPrevious = { viewModel.previous() },
|
||||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||||
onExpandPlaylist = expandPlaylist,
|
onExpandPlaylist = ::expandPlaylist,
|
||||||
onCollapsePlaylist = collapsePlaylistToControls,
|
onCollapsePlaylist = ::closePlaylist,
|
||||||
onSelectQueueItem = { id ->
|
onSelectQueueItem = { id ->
|
||||||
viewModel.playQueueItem(id, TV_CONTROLS_AUTO_HIDE_MS)
|
viewModel.playQueueItem(id)
|
||||||
collapsePlaylistToControls()
|
|
||||||
},
|
},
|
||||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||||
@@ -285,25 +330,90 @@ fun TvPlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
TvPlayerLoadingErrorEndCard(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onRetry = { viewModel.retry() },
|
onRetry = { viewModel.retry() },
|
||||||
onNext = nextAndShowControls,
|
onNext = { viewModel.next() },
|
||||||
onReplay = {
|
onReplay = {
|
||||||
viewModel.seekTo(0L)
|
viewModel.seekTo(0L)
|
||||||
togglePlayPauseAndShowControls()
|
viewModel.resumePlayback()
|
||||||
},
|
},
|
||||||
onDismissError = { viewModel.clearError() }
|
onDismissError = { viewModel.clearError() }
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
ValueChangeTimedVisibility(
|
||||||
visible = stopFeedbackVisible,
|
value = resumeStopFeedbackCounter,
|
||||||
enter = fadeIn(),
|
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
) {
|
) {
|
||||||
TvPlayerHiddenStopFeedback()
|
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -327,43 +437,74 @@ fun TvPlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun handleTvPlayerRootKeyEvent(
|
@Composable
|
||||||
event: androidx.compose.ui.input.key.KeyEvent,
|
private fun HiddenTvSeekTimeline(
|
||||||
isPlaying: Boolean,
|
positionMs: Long,
|
||||||
controlsVisible: Boolean,
|
durationMs: Long,
|
||||||
isPlaylistExpanded: Boolean,
|
bufferedMs: Long,
|
||||||
trackPanelType: TvTrackPanelType?,
|
chapterMarkers: List<TimedMarker>,
|
||||||
onCloseTrackPanel: () -> Unit,
|
adMarkers: List<TimedMarker>,
|
||||||
onCollapsePlaylist: () -> Unit,
|
modifier: Modifier = Modifier
|
||||||
onHideControls: () -> Unit,
|
) {
|
||||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
|
||||||
onSeekRelative: (Long) -> Unit,
|
|
||||||
onShowControls: () -> Unit,
|
|
||||||
onTogglePlayPause: () -> Unit
|
|
||||||
): Boolean {
|
|
||||||
if (event.type != KeyEventType.KeyDown) return false
|
|
||||||
|
|
||||||
if (event.key == Key.Back || event.key == Key.Escape) {
|
Column(
|
||||||
return when {
|
modifier = modifier
|
||||||
trackPanelType != null -> {
|
.fillMaxWidth()
|
||||||
onCloseTrackPanel()
|
.clip(RoundedCornerShape(16.dp))
|
||||||
true
|
.background(Color.Transparent)
|
||||||
}
|
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
TvPlayerTimeRow(
|
||||||
|
positionMs = positionMs,
|
||||||
|
durationMs = durationMs,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
PlayerSeekBarTrack(
|
||||||
|
positionMs = positionMs,
|
||||||
|
durationMs = safeDuration,
|
||||||
|
bufferedMs = bufferedMs,
|
||||||
|
chapterMarkers = chapterMarkers,
|
||||||
|
adMarkers = adMarkers,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(32.dp),
|
||||||
|
isFocused = false,
|
||||||
|
thumbRadius = 7.dp,
|
||||||
|
focusedThumbRadius = 9.dp,
|
||||||
|
focusedThumbHaloRadiusDelta = 0.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isPlaylistExpanded -> {
|
@Composable
|
||||||
onCollapsePlaylist()
|
private fun TvPlayerClock(modifier: Modifier = Modifier) {
|
||||||
true
|
var currentTime by remember { mutableStateOf(LocalTime.now()) }
|
||||||
}
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
controlsVisible -> {
|
currentTime = LocalTime.now()
|
||||||
onHideControls()
|
delay(1_000L)
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
|
||||||
|
Text(
|
||||||
|
text = formatter.format(currentTime),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun handleTvPlayerRootKeyEvent(
|
||||||
|
event: KeyEvent,
|
||||||
|
controlsVisible: Boolean,
|
||||||
|
popupVisible: Boolean,
|
||||||
|
onTogglePlayback: () -> Unit,
|
||||||
|
onSeekRelative: (Long) -> Unit,
|
||||||
|
onShowControls: () -> Unit,
|
||||||
|
): Boolean {
|
||||||
|
if (event.type != KeyEventType.KeyDown) return false
|
||||||
|
|
||||||
if (!controlsVisible) {
|
if (!controlsVisible) {
|
||||||
return when (event.key) {
|
return when (event.key) {
|
||||||
@@ -373,7 +514,7 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionRight -> {
|
Key.DirectionRight -> {
|
||||||
onSeekRelative(10_000)
|
onSeekRelative(30_000)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,12 +524,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Key.DirectionCenter, Key.Enter -> {
|
Key.DirectionCenter, Key.Enter -> {
|
||||||
if (isPlaying) {
|
if (popupVisible) {
|
||||||
onPausePlaybackWithoutShowingControls()
|
// Do nothing because the focused component is not he root, but an overlay
|
||||||
|
false
|
||||||
} else {
|
} else {
|
||||||
onResumePlaybackWithoutShowingControls()
|
onTogglePlayback()
|
||||||
|
true
|
||||||
}
|
}
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
@@ -399,9 +541,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvPlayerHiddenStopFeedback(
|
internal fun TvPlayerStopFeedback(
|
||||||
|
stopped: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
if (!stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.testTag(TvPlayerHiddenStopFeedbackTag)
|
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||||
@@ -412,7 +558,7 @@ internal fun TvPlayerHiddenStopFeedback(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Pause,
|
imageVector = Icons.Outlined.Pause,
|
||||||
contentDescription = "Pause playback",
|
contentDescription = "Play/Pause playback",
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.size(72.dp)
|
modifier = Modifier.size(72.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ 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.Row
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,6 +31,7 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.semantics.disabled
|
import androidx.compose.ui.semantics.disabled
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,6 +41,7 @@ internal fun TvIconButton(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
size: Int = 52,
|
size: Int = 52,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
label: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -49,41 +54,74 @@ internal fun TvIconButton(
|
|||||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||||
label = "border"
|
label = "border"
|
||||||
)
|
)
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isFocused) {
|
||||||
|
scheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||||
|
},
|
||||||
|
label = "background"
|
||||||
|
)
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = if (isFocused) scheme.onPrimaryContainer else scheme.onSurface,
|
||||||
|
label = "content"
|
||||||
|
)
|
||||||
|
val shape = RoundedCornerShape(50)
|
||||||
|
val iconSize = (size - 24).coerceAtLeast(0).dp
|
||||||
|
|
||||||
Box(
|
val buttonModifier = modifier
|
||||||
modifier = modifier
|
.graphicsLayer {
|
||||||
.graphicsLayer {
|
scaleX = scale
|
||||||
scaleX = scale
|
scaleY = 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()
|
||||||
}
|
}
|
||||||
.alpha(if (enabled) 1f else 0.4f)
|
}
|
||||||
.widthIn(min = size.dp)
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.height(size.dp)
|
.clickable(enabled = enabled) { onClick() }
|
||||||
.border(
|
|
||||||
width = if (isFocused) 2.dp else 0.dp,
|
if (label == null) {
|
||||||
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(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
tint = scheme.onBackground,
|
tint = contentColor,
|
||||||
modifier = Modifier.padding(8.dp)
|
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"
|
||||||
@@ -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
|
||||||
@@ -47,7 +44,7 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
|
|
||||||
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
|
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
|
||||||
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
|
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvPlayerLoadingErrorEndCard(
|
internal fun TvPlayerLoadingErrorEndCard(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package hu.bbara.purefin.ui.screen.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -7,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.player.model.PlayerUiState
|
|
||||||
import hu.bbara.purefin.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import androidx.compose.ui.input.key.key
|
|||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.player.model.TimedMarker
|
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvPlayerSeekBar(
|
internal fun TvPlayerSeekBar(
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
package hu.bbara.purefin.ui.screen.player.components
|
package hu.bbara.purefin.ui.screen.player.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
import androidx.compose.material.icons.outlined.ClosedCaption
|
import androidx.compose.material.icons.outlined.ClosedCaption
|
||||||
import androidx.compose.material.icons.outlined.HighQuality
|
import androidx.compose.material.icons.outlined.HighQuality
|
||||||
import androidx.compose.material.icons.outlined.Language
|
import androidx.compose.material.icons.outlined.Language
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -44,8 +49,8 @@ import androidx.compose.ui.input.key.type
|
|||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.player.model.PlayerUiState
|
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||||
import hu.bbara.purefin.player.model.TrackOption
|
import hu.bbara.purefin.core.player.model.TrackOption
|
||||||
|
|
||||||
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
|
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
|
||||||
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
|
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
|
||||||
@@ -211,23 +216,42 @@ private fun TvTrackOptionRow(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.primaryContainer
|
||||||
|
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
|
||||||
|
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||||
|
},
|
||||||
|
label = "trackOptionBackground"
|
||||||
|
)
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.onPrimaryContainer
|
||||||
|
selected -> scheme.onSecondaryContainer
|
||||||
|
else -> scheme.onSurface
|
||||||
|
},
|
||||||
|
label = "trackOptionContent"
|
||||||
|
)
|
||||||
|
val borderColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isFocused -> scheme.primary
|
||||||
|
selected -> scheme.secondary
|
||||||
|
else -> scheme.outlineVariant.copy(alpha = 0.45f)
|
||||||
|
},
|
||||||
|
label = "trackOptionBorder"
|
||||||
|
)
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(
|
.border(
|
||||||
if (isFocused) {
|
width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
|
||||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
color = borderColor,
|
||||||
} else {
|
shape = shape
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(
|
|
||||||
if (isFocused) scheme.primary.copy(alpha = 0.3f)
|
|
||||||
else if (selected) scheme.primary.copy(alpha = 0.15f)
|
|
||||||
else scheme.surfaceVariant.copy(alpha = 0.6f)
|
|
||||||
)
|
)
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) {
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
@@ -244,11 +268,25 @@ private fun TvTrackOptionRow(
|
|||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = label,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = scheme.onSurface,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
) {
|
||||||
)
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = contentColor,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,53 +2,62 @@ package hu.bbara.purefin.ui.screen.series
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import androidx.tv.material3.MaterialTheme
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
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.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
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.TvEpisodeCarousel
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
||||||
import hu.bbara.purefin.navigation.SeriesDto
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.model.Season
|
|
||||||
import hu.bbara.purefin.model.Series
|
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import androidx.tv.material3.Text as TvText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvSeriesScreen(
|
fun TvSeriesScreen(
|
||||||
series: SeriesDto,
|
series: SeriesDto,
|
||||||
|
focusedSeasonId: UUID? = null,
|
||||||
|
focusedEpisodeId: UUID? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SeriesViewModel = hiltViewModel()
|
viewModel: SeriesViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(series.id) {
|
LaunchedEffect(series) {
|
||||||
viewModel.selectSeries(series.id)
|
viewModel.selectSeries(series)
|
||||||
}
|
}
|
||||||
|
|
||||||
val series = viewModel.series.collectAsState()
|
val series = viewModel.series.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val seriesData = series.value
|
val seriesData = series.value
|
||||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
if (seriesData != null) {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
|
selectSeason = viewModel::selectSeason,
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
|
focusedSeasonId = focusedSeasonId,
|
||||||
|
focusedEpisodeId = focusedEpisodeId,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -59,93 +68,105 @@ fun TvSeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesScreenContent(
|
internal fun TvSeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
|
selectSeason: (UUID, UUID) -> Unit,
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
|
focusedSeasonId: UUID? = null,
|
||||||
|
focusedEpisodeId: UUID? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
val defaultSeason = series.defaultSeason(focusedSeasonId)
|
||||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
mutableStateOf(defaultSeason?.id)
|
||||||
val firstContentFocusRequester = remember { FocusRequester() }
|
}
|
||||||
|
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, nextUpEpisode?.id) {
|
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||||
withFrameNanos { }
|
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||||
if (nextUpEpisode != null) {
|
|
||||||
playFocusRequester.requestFocus()
|
|
||||||
} else {
|
|
||||||
firstContentFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
resetScrollKey = series.id,
|
resetScrollKey = series.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "series-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = it
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvSeriesHeroSection(
|
TvSeriesHeroSection(
|
||||||
series = series,
|
series = series,
|
||||||
nextUpEpisode = nextUpEpisode,
|
|
||||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
|
||||||
playFocusRequester = playFocusRequester,
|
|
||||||
firstContentFocusRequester = firstContentFocusRequester,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "series-overview") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
MediaDetailOverviewSection(
|
if (selectedSeason != null) {
|
||||||
synopsis = series.synopsis,
|
TvSeasonTabs(
|
||||||
modifier = Modifier.fillMaxWidth()
|
seasons = series.allSeasons,
|
||||||
)
|
selectedSeason = selectedSeason,
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||||
}
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
}
|
requestSelectedItemFocus = initialFocusedEpisodeId == null &&
|
||||||
item(key = "series-season-tabs") {
|
!waitingForInitialEpisodes &&
|
||||||
TvSeasonTabs(
|
!requestedInitialSeasonFocus,
|
||||||
seasons = series.seasons,
|
onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
|
||||||
selectedSeason = selectedSeason,
|
onSelect = { selectedSeasonId = it.id },
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
modifier = Modifier.fillMaxWidth()
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
)
|
||||||
onSelect = { selectedSeason = it },
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
modifier = it
|
TvEpisodeCarousel(
|
||||||
)
|
episodes = selectedSeason.episodes,
|
||||||
}
|
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||||
item(key = "series-episodes") {
|
focusedEpisodeId = initialFocusedEpisodeId,
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
|
||||||
TvEpisodeCarousel(
|
onFocusRequested = { requestedInitialEpisodeFocus = true },
|
||||||
episodes = selectedSeason.episodes,
|
upFocusRequester = seasonTabFocusRequester,
|
||||||
modifier = it
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
if (series.cast.isNotEmpty()) {
|
TvText(
|
||||||
item(key = "series-cast") {
|
text = "Loading seasons...",
|
||||||
Column(modifier = it) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
fontSize = 13.sp,
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
)
|
||||||
CastRow(cast = series.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.defaultSeason(): Season {
|
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||||
for (season in seasons) {
|
if (focusedSeasonId != null) {
|
||||||
if (season.episodes.any { !it.watched }) {
|
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||||
return season
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return seasons.first()
|
|
||||||
|
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
private fun Season.nextUpEpisode(): Episode? {
|
||||||
season.episodes.firstOrNull { !it.watched }
|
return episodes.firstOrNull { !it.watched } ?: episodes.firstOrNull()
|
||||||
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
}
|
||||||
|
|
||||||
|
private fun Season.focusTargetEpisodeId(focusedEpisodeId: UUID?): UUID? {
|
||||||
|
val focusedEpisode = episodes.firstOrNull { it.id == focusedEpisodeId }
|
||||||
|
return focusedEpisode?.id ?: nextUpEpisode()?.id
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package hu.bbara.purefin.ui.screen.series.components
|
package hu.bbara.purefin.ui.screen.series.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
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.horizontalScroll
|
import androidx.compose.foundation.focusGroup
|
||||||
|
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.Row
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -16,12 +18,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
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.PlayCircle
|
import androidx.compose.material.icons.outlined.PlayCircle
|
||||||
@@ -29,17 +30,20 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -49,25 +53,22 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import hu.bbara.purefin.core.image.ArtworkKind
|
||||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
|
||||||
import hu.bbara.purefin.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
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 SeriesPlayButtonTag = "series-play-button"
|
|
||||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||||
|
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesMetaChips(series: Series) {
|
internal fun TvSeriesMetaChips(series: Series) {
|
||||||
@@ -77,29 +78,55 @@ internal fun TvSeriesMetaChips(series: Series) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
internal fun TvSeasonTabs(
|
internal fun TvSeasonTabs(
|
||||||
seasons: List<Season>,
|
seasons: List<Season>,
|
||||||
selectedSeason: Season?,
|
selectedSeason: Season?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
selectedItemFocusRequester: FocusRequester? = null,
|
||||||
firstItemTestTag: String? = null,
|
firstItemTestTag: String? = null,
|
||||||
|
requestSelectedItemFocus: Boolean = false,
|
||||||
|
onSelectedItemFocusRequested: () -> Unit = {},
|
||||||
onSelect: (Season) -> Unit
|
onSelect: (Season) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
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
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.then(
|
||||||
.horizontalScroll(rememberScrollState()),
|
if (selectedItemFocusRequester != null) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
Modifier.focusRestorer(selectedItemFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier.focusRestorer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.focusGroup()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
seasons.forEachIndexed { index, season ->
|
itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
|
||||||
TvSeasonTab(
|
TvSeasonTab(
|
||||||
name = season.name,
|
name = season.name,
|
||||||
isSelected = season == selectedSeason,
|
isSelected = index == selectedSeasonIndex,
|
||||||
onSelect = { onSelect(season) },
|
onFocused = { onSelect(season) },
|
||||||
|
onClick = { onSelect(season) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(
|
.then(
|
||||||
if (index == 0 && firstItemFocusRequester != null) {
|
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
Modifier.focusRequester(selectedItemFocusRequester)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
@@ -120,58 +147,128 @@ internal fun TvSeasonTabs(
|
|||||||
private fun TvSeasonTab(
|
private fun TvSeasonTab(
|
||||||
name: String,
|
name: String,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onSelect: () -> Unit,
|
onFocused: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
val color = if (isSelected) scheme.primary else scheme.onSurface
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
val isFocused = 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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(bottom = 8.dp)
|
.onFocusChanged { state ->
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
if (state.isFocused) {
|
||||||
.clickable { onSelect() }
|
onFocused()
|
||||||
|
isFocused.value = true
|
||||||
|
} else {
|
||||||
|
isFocused.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
|
||||||
) {
|
) {
|
||||||
Text(
|
TvText(
|
||||||
text = name,
|
text = name,
|
||||||
color = color,
|
color = color,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||||
)
|
maxLines = 1,
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
overflow = TextOverflow.Ellipsis,
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(underlineHeight)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
.width(52.dp)
|
|
||||||
.background(underlineColor)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
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 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) {
|
LaunchedEffect(episodes, targetEpisodeId) {
|
||||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
if (episodes.none { it.id == rowFocusedEpisodeId }) {
|
||||||
if (firstUnwatchedIndex != 0) {
|
rowFocusedEpisodeId = targetEpisodeId
|
||||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
}
|
||||||
|
|
||||||
|
if (targetEpisodeIndex > 0) {
|
||||||
|
listState.scrollToItem(targetEpisodeIndex)
|
||||||
} else {
|
} else {
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyRow(
|
LaunchedEffect(requestFocus, targetEpisodeId) {
|
||||||
state = listState,
|
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
|
||||||
modifier = modifier,
|
rowFocusedEpisodeId = targetEpisodeId
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
if (targetEpisodeIndex > 0) {
|
||||||
) {
|
listState.scrollToItem(targetEpisodeIndex)
|
||||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
} else {
|
||||||
TvEpisodeCard(episode = episode)
|
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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +276,6 @@ internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Mod
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesHeroSection(
|
internal fun TvSeriesHeroSection(
|
||||||
series: Series,
|
series: Series,
|
||||||
nextUpEpisode: Episode?,
|
|
||||||
onPlayEpisode: (Episode) -> Unit,
|
|
||||||
playFocusRequester: FocusRequester,
|
|
||||||
firstContentFocusRequester: FocusRequester,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -190,81 +283,46 @@ internal fun TvSeriesHeroSection(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = series.name,
|
text = series.name,
|
||||||
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(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
TvSeriesMetaChips(series = series)
|
TvSeriesMetaChips(series = series)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
if (nextUpEpisode != null) {
|
Text(
|
||||||
Text(
|
text = "Overview",
|
||||||
text = nextUpEpisode.heroStatusText(),
|
color = scheme.onBackground,
|
||||||
color = scheme.primary,
|
fontSize = 13.sp,
|
||||||
fontSize = 18.sp,
|
fontWeight = FontWeight.Bold
|
||||||
fontWeight = FontWeight.SemiBold,
|
)
|
||||||
maxLines = 1,
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
overflow = TextOverflow.Ellipsis
|
Text(
|
||||||
)
|
text = series.synopsis,
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
color = mutedStrong,
|
||||||
Text(
|
fontSize = 12.sp,
|
||||||
text = nextUpEpisode.title,
|
lineHeight = 16.sp,
|
||||||
color = scheme.onBackground,
|
maxLines = 3,
|
||||||
fontSize = 22.sp,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = FontWeight.SemiBold,
|
modifier = Modifier.fillMaxWidth(0.4f)
|
||||||
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 = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
|
||||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
|
||||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
|
||||||
modifier = Modifier
|
|
||||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
|
||||||
.focusRequester(playFocusRequester)
|
|
||||||
.focusProperties { down = firstContentFocusRequester }
|
|
||||||
.testTag(SeriesPlayButtonTag),
|
|
||||||
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
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Choose a season below to start watching.",
|
|
||||||
color = mutedStrong,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvEpisodeCard(
|
private fun TvEpisodeCard(
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
episode: Episode,
|
||||||
episode: Episode
|
onPlayEpisode: () -> Unit,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
@@ -272,15 +330,14 @@ private fun TvEpisodeCard(
|
|||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.width(260.dp)
|
.width(260.dp)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged {
|
||||||
.clickable { viewModel.onSelectEpisode(
|
isFocused = it.isFocused
|
||||||
seriesId = episode.seriesId,
|
if (it.isFocused) onFocused()
|
||||||
seasonId = episode.seasonId,
|
}
|
||||||
episodeId = episode.id
|
.clickable { onPlayEpisode() },
|
||||||
) },
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -337,7 +394,6 @@ private fun TvEpisodeCard(
|
|||||||
} else {
|
} else {
|
||||||
WatchStateBadge(
|
WatchStateBadge(
|
||||||
watched = episode.watched,
|
watched = episode.watched,
|
||||||
started = (episode.progress ?: 0.0) > 0.0,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
@@ -367,15 +423,23 @@ private fun TvEpisodeCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||||
MediaCastRow(
|
// MediaCastRow(
|
||||||
cast = cast,
|
// cast = cast,
|
||||||
modifier = modifier,
|
// modifier = modifier,
|
||||||
cardWidth = 84.dp,
|
// cardWidth = 84.dp,
|
||||||
nameSize = 11.sp,
|
// nameSize = 11.sp,
|
||||||
roleSize = 10.sp
|
// roleSize = 10.sp
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Episode.heroStatusText(): String {
|
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
|
||||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
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,6 +1,6 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.MediaKind
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
@@ -8,7 +8,7 @@ import hu.bbara.purefin.model.Series
|
|||||||
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
import hu.bbara.purefin.core.image.ArtworkKind
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -95,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.",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import hu.bbara.purefin.model.LibraryKind
|
|||||||
import hu.bbara.purefin.model.MediaKind
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
import hu.bbara.purefin.feature.browse.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -104,12 +104,13 @@ 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 = LibraryKind.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = isEmpty
|
isEmpty = isEmpty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -11,24 +11,29 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "hu.bbara.purefin"
|
namespace = "hu.bbara.purefin"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "hu.bbara.purefin"
|
applicationId = "hu.bbara.purefin"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1000002
|
|
||||||
versionName = "0.1"
|
versionName = "0.1"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app-debug/update.json"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
// Enables code-related app optimization.
|
// Enables code-related app optimization.
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
|
||||||
// Enables resource shrinking.
|
// Enables resource shrinking.
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
|
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app/update.json"
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
)
|
)
|
||||||
@@ -40,6 +45,20 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withBuildType("debug")) { variant ->
|
||||||
|
variant.outputs.forEach { output ->
|
||||||
|
output.versionCode.set(providers.gradleProperty("purefinDebugVersionCode").map { it.toInt() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onVariants(selector().withBuildType("release")) { variant ->
|
||||||
|
variant.outputs.forEach { output ->
|
||||||
|
output.versionCode.set(providers.gradleProperty("purefinReleaseVersionCode").map { it.toInt() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +76,7 @@ dependencies {
|
|||||||
implementation(project(":core-ui"))
|
implementation(project(":core-ui"))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="purefin_logo_background">#201337</color>
|
<color name="ic_launcher_foreground">#E53935</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".PurefinApplication"
|
android:name=".PurefinApplication"
|
||||||
@@ -12,11 +14,15 @@
|
|||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/purefin_logo"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
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="${updateManifestUrl}" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".PurefinActivity"
|
android:name=".PurefinActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -33,9 +39,12 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.Purefin" />
|
android:theme="@style/Theme.Purefin" />
|
||||||
<service
|
<service
|
||||||
android:name="hu.bbara.purefin.download.PurefinDownloadService"
|
android:name="hu.bbara.purefin.core.download.PurefinDownloadService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<receiver
|
||||||
|
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package hu.bbara.purefin
|
|||||||
|
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -14,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
@@ -36,14 +41,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import hu.bbara.purefin.data.SessionBootstrapper
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||||
|
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.navigation.NavigationCommand
|
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
|
||||||
import hu.bbara.purefin.navigation.NavigationManager
|
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||||
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
|
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
@@ -131,6 +140,7 @@ class PurefinActivity : ComponentActivity() {
|
|||||||
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp(
|
fun MainApp(
|
||||||
userSessionRepository: UserSessionRepository,
|
userSessionRepository: UserSessionRepository,
|
||||||
@@ -138,7 +148,8 @@ class PurefinActivity : ComponentActivity() {
|
|||||||
navigationManager: NavigationManager
|
navigationManager: NavigationManager
|
||||||
) {
|
) {
|
||||||
var sessionLoaded by remember { mutableStateOf(false) }
|
var sessionLoaded by remember { mutableStateOf(false) }
|
||||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
var showExitDialog by remember { mutableStateOf(false) }
|
||||||
|
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
userSessionRepository.isLoggedIn.collect {
|
userSessionRepository.isLoggedIn.collect {
|
||||||
@@ -147,13 +158,14 @@ class PurefinActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionLoaded) {
|
if (!sessionLoaded) {
|
||||||
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||||
return
|
} else if (isLoggedIn) {
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||||
|
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
|
||||||
val appEntryProvider =
|
val appEntryProvider =
|
||||||
entryProvider {
|
entryProvider {
|
||||||
entryBuilders.forEach { builder -> builder() }
|
entryBuilders.forEach { builder -> builder() }
|
||||||
@@ -162,7 +174,13 @@ class PurefinActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(navigationManager, backStack) {
|
LaunchedEffect(navigationManager, backStack) {
|
||||||
navigationManager.commands.collect { command ->
|
navigationManager.commands.collect { command ->
|
||||||
when (command) {
|
when (command) {
|
||||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
NavigationCommand.Pop -> {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeLastOrNull()
|
||||||
|
} else {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||||
is NavigationCommand.ReplaceAll -> {
|
is NavigationCommand.ReplaceAll -> {
|
||||||
backStack.clear()
|
backStack.clear()
|
||||||
@@ -172,48 +190,80 @@ class PurefinActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
BackHandler(enabled = backStack.size == 1) {
|
||||||
LocalNavigationManager provides navigationManager,
|
showExitDialog = true
|
||||||
LocalNavigationBackStack provides backStack.toList()
|
}
|
||||||
) {
|
|
||||||
NavDisplay(
|
SharedTransitionLayout {
|
||||||
backStack = backStack,
|
CompositionLocalProvider(
|
||||||
onBack = { navigationManager.pop() },
|
LocalNavigationManager provides navigationManager,
|
||||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
LocalNavigationBackStack provides backStack.toList(),
|
||||||
transitionSpec = {
|
LocalSharedTransitionScope provides this,
|
||||||
fadeIn(
|
LocalHomeMediaSharedBoundsKey provides homeMediaSharedBoundsKey,
|
||||||
animationSpec = tween(
|
LocalSetHomeMediaSharedBoundsKey provides { key ->
|
||||||
durationMillis = 140
|
homeMediaSharedBoundsKey = key
|
||||||
)
|
}
|
||||||
) togetherWith
|
) {
|
||||||
fadeOut(animationSpec = tween(durationMillis = 140))
|
NavDisplay(
|
||||||
},
|
backStack = backStack,
|
||||||
popTransitionSpec = {
|
onBack = {
|
||||||
fadeIn(
|
if (backStack.size > 1) {
|
||||||
animationSpec = tween(
|
navigationManager.pop()
|
||||||
durationMillis = 140
|
} else {
|
||||||
)
|
showExitDialog = true
|
||||||
) togetherWith
|
}
|
||||||
fadeOut(animationSpec = tween(durationMillis = 140))
|
},
|
||||||
},
|
modifier = Modifier
|
||||||
predictivePopTransitionSpec = { _ ->
|
.fillMaxSize()
|
||||||
fadeIn(
|
.background(backgroundDark)
|
||||||
animationSpec = tween(
|
.semantics { testTagsAsResourceId = true },
|
||||||
durationMillis = 140
|
transitionSpec = {
|
||||||
)
|
fadeIn(
|
||||||
) togetherWith
|
animationSpec = tween(
|
||||||
fadeOut(animationSpec = tween(durationMillis = 140))
|
durationMillis = 140
|
||||||
},
|
)
|
||||||
entryDecorators =
|
) togetherWith
|
||||||
listOf(
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
},
|
||||||
rememberViewModelStoreNavEntryDecorator(),
|
popTransitionSpec = {
|
||||||
),
|
fadeIn(
|
||||||
entryProvider = appEntryProvider
|
animationSpec = tween(
|
||||||
)
|
durationMillis = 140
|
||||||
|
)
|
||||||
|
) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
||||||
|
},
|
||||||
|
predictivePopTransitionSpec = { _ ->
|
||||||
|
fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 140
|
||||||
|
)
|
||||||
|
) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
||||||
|
},
|
||||||
|
entryDecorators =
|
||||||
|
listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator(),
|
||||||
|
),
|
||||||
|
entryProvider = appEntryProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LoginScreen()
|
BackHandler {
|
||||||
|
showExitDialog = true
|
||||||
|
}
|
||||||
|
LoginScreen(
|
||||||
|
modifier = Modifier.semantics { testTagsAsResourceId = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExitDialog) {
|
||||||
|
ExitAppDialog(
|
||||||
|
onCloseConfirmed = { finishAndRemoveTask() },
|
||||||
|
onDismiss = { showExitDialog = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package hu.bbara.purefin
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class PurefinApplication : Application() {
|
class PurefinApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
}
|
super.onCreate()
|
||||||
|
PurefinLogger.initialize(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||||
import dagger.multibindings.IntoSet
|
import dagger.multibindings.IntoSet
|
||||||
import hu.bbara.purefin.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.navigation.appRouteEntryBuilder
|
import hu.bbara.purefin.navigation.appRouteEntryBuilder
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import dagger.Module
|
|||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import hu.bbara.purefin.data.PlaybackProfileFamily
|
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
package hu.bbara.purefin.navigation
|
package hu.bbara.purefin.navigation
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
|
|
||||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||||
|
|
||||||
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }
|
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
|
||||||
|
|
||||||
|
val LocalNavSharedAnimatedVisibilityScope =
|
||||||
|
compositionLocalOf<AnimatedVisibilityScope?> { null }
|
||||||
|
|
||||||
|
val LocalHomeMediaSharedBoundsKey = compositionLocalOf<String?> { null }
|
||||||
|
|
||||||
|
val LocalSetHomeMediaSharedBoundsKey = staticCompositionLocalOf<(String) -> Unit> { {} }
|
||||||
|
|
||||||
|
const val HOME_SEARCH_SHARED_BOUNDS_KEY = "home_search_shared_bounds"
|
||||||
|
|||||||
@@ -1,25 +1,53 @@
|
|||||||
package hu.bbara.purefin.navigation
|
package hu.bbara.purefin.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
|
import androidx.navigation3.ui.LocalNavAnimatedContentScope
|
||||||
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.ui.screen.library.LibraryScreen
|
import hu.bbara.purefin.ui.screen.library.LibraryScreen
|
||||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||||
import hu.bbara.purefin.ui.screen.AppScreen
|
import hu.bbara.purefin.ui.screen.AppScreen
|
||||||
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
|
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.search.SearchScreen
|
||||||
import hu.bbara.purefin.ui.screen.movie.MovieScreen
|
import hu.bbara.purefin.ui.screen.movie.MovieScreen
|
||||||
|
import hu.bbara.purefin.ui.screen.settings.SettingsScreen
|
||||||
import hu.bbara.purefin.ui.screen.series.SeriesScreen
|
import hu.bbara.purefin.ui.screen.series.SeriesScreen
|
||||||
|
|
||||||
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||||
entry<Route.Home> {
|
entry<Route.Home> {
|
||||||
AppScreen()
|
CompositionLocalProvider(
|
||||||
|
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||||
|
) {
|
||||||
|
AppScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry<Route.HomeSearchRoute> {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||||
|
) {
|
||||||
|
SearchScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry<Route.MovieRoute> {
|
entry<Route.MovieRoute> {
|
||||||
MovieScreen(movie = it.item)
|
CompositionLocalProvider(
|
||||||
|
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||||
|
) {
|
||||||
|
MovieScreen(movie = it.item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry<Route.SeriesRoute> {
|
entry<Route.SeriesRoute> {
|
||||||
SeriesScreen(series = it.item)
|
CompositionLocalProvider(
|
||||||
|
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||||
|
) {
|
||||||
|
SeriesScreen(series = it.item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry<Route.EpisodeRoute> {
|
entry<Route.EpisodeRoute> {
|
||||||
EpisodeScreen(episode = it.item)
|
CompositionLocalProvider(
|
||||||
|
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||||
|
) {
|
||||||
|
EpisodeScreen(episode = it.item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry<Route.LibraryRoute> {
|
entry<Route.LibraryRoute> {
|
||||||
LibraryScreen(library = it.library)
|
LibraryScreen(library = it.library)
|
||||||
@@ -27,4 +55,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
|||||||
entry<Route.LoginRoute> {
|
entry<Route.LoginRoute> {
|
||||||
LoginScreen()
|
LoginScreen()
|
||||||
}
|
}
|
||||||
|
entry<Route.SettingsRoute> {
|
||||||
|
SettingsScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -11,12 +12,14 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||||
import hu.bbara.purefin.ui.screen.player.PlayerScreen
|
import hu.bbara.purefin.ui.screen.player.PlayerScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlayerActivity : ComponentActivity() {
|
class PlayerActivity : ComponentActivity() {
|
||||||
|
private val viewModel: PlayerViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -43,6 +46,18 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (!isChangingConfigurations) {
|
||||||
|
viewModel.pausePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
enterImmersiveMode()
|
||||||
|
}
|
||||||
|
|
||||||
private fun enterImmersiveMode() {
|
private fun enterImmersiveMode() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Person
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import hu.bbara.purefin.feature.search.SearchViewModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DefaultTopBar(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
searchViewModel: SearchViewModel = hiltViewModel(),
|
|
||||||
onProfileClick: () -> Unit = {},
|
|
||||||
onSettingsClick: () -> Unit = {},
|
|
||||||
onLogoutClick: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val searchResult = searchViewModel.searchResult.collectAsState()
|
|
||||||
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
|
||||||
var isSearchExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(scheme.background.copy(alpha = 0.95f))
|
|
||||||
.zIndex(1f)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.CenterEnd
|
|
||||||
) {
|
|
||||||
if (!isSearchExpanded) {
|
|
||||||
Box {
|
|
||||||
IconButton(
|
|
||||||
onClick = { isProfileMenuExpanded = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.size(56.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Person,
|
|
||||||
contentDescription = "Profile",
|
|
||||||
modifier = Modifier.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = isProfileMenuExpanded,
|
|
||||||
onDismissRequest = { isProfileMenuExpanded = false },
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Profile") },
|
|
||||||
onClick = {
|
|
||||||
isProfileMenuExpanded = false
|
|
||||||
onProfileClick()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Settings") },
|
|
||||||
onClick = {
|
|
||||||
isProfileMenuExpanded = false
|
|
||||||
onSettingsClick()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Logout") },
|
|
||||||
onClick = {
|
|
||||||
isProfileMenuExpanded = false
|
|
||||||
onLogoutClick()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package hu.bbara.purefin.ui.common.card
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
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 hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
import hu.bbara.purefin.ui.model.MediaAction
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MediaImageCard(
|
||||||
|
imageUrl: String?,
|
||||||
|
title: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
popupActions: List<MediaAction> = emptyList(),
|
||||||
|
imageModifier: Modifier = Modifier,
|
||||||
|
shapeSize: Dp = 12.dp,
|
||||||
|
imageAspectRatio: Float = 16f / 10f,
|
||||||
|
titleStyle: TextStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
subtitleStyle: TextStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
textPadding: PaddingValues = PaddingValues(8.dp),
|
||||||
|
imageOverlay: @Composable BoxScope.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val shape = RoundedCornerShape(shapeSize)
|
||||||
|
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = shape,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = if (popupActions.isNotEmpty()) {
|
||||||
|
{ showBottomSheet = true }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Box(
|
||||||
|
modifier = imageModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(imageAspectRatio)
|
||||||
|
.clip(RoundedCornerShape(topStart = shapeSize, topEnd = shapeSize))
|
||||||
|
.background(scheme.surface)
|
||||||
|
) {
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
imageOverlay()
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(textPadding)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = titleStyle,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
subtitle
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { text ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = subtitleStyle,
|
||||||
|
color = scheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (popupActions.isNotEmpty()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showBottomSheet = true },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.MoreVert,
|
||||||
|
contentDescription = "More actions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBottomSheet) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { showBottomSheet = false },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
popupActions.forEach { action ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(text = action.name) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
action.onClick()
|
||||||
|
showBottomSheet = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
import hu.bbara.purefin.ui.model.SeriesUiModel
|
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -117,7 +117,6 @@ fun PosterCardContent(
|
|||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(indicatorPadding),
|
.padding(indicatorPadding),
|
||||||
watched = model.watched,
|
watched = model.watched,
|
||||||
started = (model.progress ?: 0f) > 0f
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -26,6 +27,8 @@ fun SectionHeader(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onActionClick: () -> Unit = {}
|
onActionClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val headerColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -36,10 +39,14 @@ fun SectionHeader(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = headerColor
|
||||||
)
|
)
|
||||||
if (actionLabel != null) {
|
if (actionLabel != null) {
|
||||||
TextButton(onClick = onActionClick) {
|
TextButton(
|
||||||
|
onClick = onActionClick,
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = headerColor)
|
||||||
|
) {
|
||||||
Text(text = actionLabel)
|
Text(text = actionLabel)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.common.image
|
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import hu.bbara.purefin.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PurefinLogo(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
contentDescription: String? = null,
|
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
|
||||||
) {
|
|
||||||
val resources = LocalContext.current.resources
|
|
||||||
val logoBitmap = remember(resources) {
|
|
||||||
BitmapFactory.decodeResource(resources, R.raw.logo_raw)?.asImageBitmap()
|
|
||||||
}
|
|
||||||
|
|
||||||
logoBitmap?.let { bitmap ->
|
|
||||||
Image(
|
|
||||||
bitmap = bitmap,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
contentScale = contentScale,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package hu.bbara.purefin.ui.common.media
|
||||||
|
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||||
|
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
fun homeMediaSharedBoundsKey(origin: String, mediaId: UUID): String =
|
||||||
|
"home_media_${origin}_$mediaId"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.homeMediaSharedBoundsSource(sharedBoundsKey: String): Modifier {
|
||||||
|
val selectedKey = LocalHomeMediaSharedBoundsKey.current
|
||||||
|
return homeMediaSharedBounds(sharedBoundsKey.takeIf { it == selectedKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Modifier.homeMediaSharedBoundsDestination(): Modifier =
|
||||||
|
homeMediaSharedBounds(LocalHomeMediaSharedBoundsKey.current)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun isHomeMediaSharedBoundsTransitionActive(): Boolean {
|
||||||
|
val sharedBoundsKey = LocalHomeMediaSharedBoundsKey.current
|
||||||
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
|
return sharedBoundsKey != null && sharedTransitionScope?.isTransitionActive == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberHomeMediaSharedBoundsClick(
|
||||||
|
sharedBoundsKey: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
): () -> Unit {
|
||||||
|
val selectSharedBoundsKey = LocalSetHomeMediaSharedBoundsKey.current
|
||||||
|
return remember(sharedBoundsKey, onClick, selectSharedBoundsKey) {
|
||||||
|
{
|
||||||
|
selectSharedBoundsKey(sharedBoundsKey)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.homeMediaSharedBounds(sharedBoundsKey: String?): Modifier {
|
||||||
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
|
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
|
||||||
|
|
||||||
|
if (
|
||||||
|
sharedBoundsKey == null ||
|
||||||
|
sharedTransitionScope == null ||
|
||||||
|
animatedVisibilityScope == null
|
||||||
|
) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
return with(sharedTransitionScope) {
|
||||||
|
this@homeMediaSharedBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key = sharedBoundsKey),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package hu.bbara.purefin.ui.common.media
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material.icons.outlined.Person
|
||||||
|
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import hu.bbara.purefin.core.download.DownloadState
|
||||||
|
import hu.bbara.purefin.model.CastMember
|
||||||
|
import hu.bbara.purefin.ui.common.button.DownloadActionButton
|
||||||
|
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
||||||
|
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||||
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
|
||||||
|
data class MediaDetailScaffoldUiModel(
|
||||||
|
val imageUrl: String,
|
||||||
|
val title: String,
|
||||||
|
val imageContentDescription: String? = null,
|
||||||
|
val titleFontSize: TextUnit = 32.sp,
|
||||||
|
val titleLineHeight: TextUnit = 38.sp,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
val subtitleFontSize: TextUnit = 14.sp,
|
||||||
|
val subtitleFontWeight: FontWeight = FontWeight.Medium,
|
||||||
|
val metadataItems: List<String> = emptyList(),
|
||||||
|
val highlightedMetadataItem: String? = null,
|
||||||
|
val actions: MediaDetailActionsUiModel? = null,
|
||||||
|
val synopsis: MediaDetailSynopsisUiModel? = null,
|
||||||
|
val playbackSettings: MediaDetailPlaybackSettingsUiModel? = null,
|
||||||
|
val cast: MediaDetailCastUiModel? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaDetailActionsUiModel(
|
||||||
|
val primaryAction: MediaDetailPrimaryActionUiModel,
|
||||||
|
val secondaryActions: List<MediaDetailSecondaryActionUiModel> = emptyList(),
|
||||||
|
val dividerThickness: Dp? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaDetailPrimaryActionUiModel(
|
||||||
|
val text: String,
|
||||||
|
val progress: Float,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
val testTag: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface MediaDetailSecondaryActionUiModel {
|
||||||
|
val testTag: String?
|
||||||
|
|
||||||
|
data class Download(
|
||||||
|
val downloadState: DownloadState,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
override val testTag: String? = null,
|
||||||
|
) : MediaDetailSecondaryActionUiModel
|
||||||
|
|
||||||
|
data class Icon(
|
||||||
|
val icon: ImageVector,
|
||||||
|
val onClick: () -> Unit = {},
|
||||||
|
override val testTag: String? = null,
|
||||||
|
) : MediaDetailSecondaryActionUiModel
|
||||||
|
|
||||||
|
data class MarkAsWatched(
|
||||||
|
val watched: Boolean,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
override val testTag: String? = null,
|
||||||
|
) : MediaDetailSecondaryActionUiModel
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MediaDetailSynopsisUiModel(
|
||||||
|
val text: String,
|
||||||
|
val bodyColor: Color? = null,
|
||||||
|
val bodyFontSize: TextUnit = 15.sp,
|
||||||
|
val bodyLineHeight: TextUnit? = 22.sp,
|
||||||
|
val titleSpacing: Dp = 12.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaDetailPlaybackSettingsUiModel(
|
||||||
|
val audioTrack: String,
|
||||||
|
val subtitles: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaDetailCastUiModel(
|
||||||
|
val members: List<CastMember>,
|
||||||
|
val title: String = "Cast",
|
||||||
|
val topSpacing: Dp = 0.dp,
|
||||||
|
val cardWidth: Dp = 96.dp,
|
||||||
|
val nameFontSize: TextUnit = 12.sp,
|
||||||
|
val roleFontSize: TextUnit = 10.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MediaDetailScaffold(
|
||||||
|
uiModel: MediaDetailScaffoldUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
imageHeight: Dp = 320.dp,
|
||||||
|
contentOverlap: Dp = 24.dp,
|
||||||
|
contentSpacing: Dp = 16.dp,
|
||||||
|
topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {},
|
||||||
|
content: @Composable ColumnScope.(Modifier) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||||
|
val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||||
|
containerColor = scheme.background,
|
||||||
|
topBar = {
|
||||||
|
if (!isHomeMediaTransitionActive) {
|
||||||
|
topBar(topBarScrollBehavior)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(bottom = innerPadding.calculateBottomPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(contentSpacing)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.homeMediaSharedBoundsDestination()
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(imageHeight)
|
||||||
|
) {
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = uiModel.imageUrl,
|
||||||
|
contentDescription = uiModel.imageContentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Transparent,
|
||||||
|
scheme.background.copy(alpha = 0.5f),
|
||||||
|
scheme.background
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isHomeMediaTransitionActive) {
|
||||||
|
val contentModifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
MediaDetailScaffoldContent(
|
||||||
|
uiModel = uiModel,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
content(contentModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.MediaDetailScaffoldContent(
|
||||||
|
uiModel: MediaDetailScaffoldUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = uiModel.title,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = uiModel.titleFontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
lineHeight = uiModel.titleLineHeight,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
uiModel.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = uiModel.subtitleFontSize,
|
||||||
|
fontWeight = uiModel.subtitleFontWeight,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiModel.metadataItems.any { it.isNotBlank() }) {
|
||||||
|
MediaMetadataFlowRow(
|
||||||
|
items = uiModel.metadataItems,
|
||||||
|
highlightedItem = uiModel.highlightedMetadataItem,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiModel.actions?.let { actions ->
|
||||||
|
MediaDetailActions(
|
||||||
|
uiModel = actions,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiModel.synopsis?.takeIf { it.text.isNotBlank() }?.let { synopsis ->
|
||||||
|
MediaSynopsis(
|
||||||
|
synopsis = synopsis.text,
|
||||||
|
bodyColor = synopsis.bodyColor ?: scheme.onSurfaceVariant,
|
||||||
|
bodyFontSize = synopsis.bodyFontSize,
|
||||||
|
bodyLineHeight = synopsis.bodyLineHeight,
|
||||||
|
titleSpacing = synopsis.titleSpacing,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiModel.playbackSettings?.let { playbackSettings ->
|
||||||
|
MediaPlaybackSettings(
|
||||||
|
backgroundColor = scheme.surface,
|
||||||
|
foregroundColor = scheme.onSurface,
|
||||||
|
audioTrack = playbackSettings.audioTrack,
|
||||||
|
subtitles = playbackSettings.subtitles,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiModel.cast?.takeIf { it.members.isNotEmpty() }?.let { cast ->
|
||||||
|
if (cast.topSpacing > 0.dp) {
|
||||||
|
Spacer(modifier = Modifier.height(cast.topSpacing))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = cast.title,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
Spacer(modifier = modifier.height(12.dp))
|
||||||
|
MediaDetailCastRow(
|
||||||
|
cast = cast,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaDetailCastRow(
|
||||||
|
cast: MediaDetailCastUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
items(cast.members) { member ->
|
||||||
|
Column(modifier = Modifier.width(cast.cardWidth)) {
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = member.imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 5f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(scheme.surfaceVariant),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
fallbackIcon = Icons.Outlined.Person
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = member.name,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = cast.nameFontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = member.role,
|
||||||
|
color = mutedStrong,
|
||||||
|
fontSize = cast.roleFontSize,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaDetailActions(
|
||||||
|
uiModel: MediaDetailActionsUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier) {
|
||||||
|
MediaResumeButton(
|
||||||
|
text = uiModel.primaryAction.text,
|
||||||
|
progress = uiModel.primaryAction.progress,
|
||||||
|
onClick = uiModel.primaryAction.onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.sizeIn(maxWidth = 200.dp)
|
||||||
|
.optionalTestTag(uiModel.primaryAction.testTag)
|
||||||
|
)
|
||||||
|
uiModel.dividerThickness?.let { dividerThickness ->
|
||||||
|
VerticalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
thickness = dividerThickness,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(48.dp)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
} ?: Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
uiModel.secondaryActions.forEach { action ->
|
||||||
|
when (action) {
|
||||||
|
is MediaDetailSecondaryActionUiModel.Download -> {
|
||||||
|
DownloadActionButton(
|
||||||
|
downloadState = action.downloadState,
|
||||||
|
onClick = action.onClick,
|
||||||
|
modifier = Modifier.optionalTestTag(action.testTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MediaDetailSecondaryActionUiModel.Icon -> {
|
||||||
|
MediaActionButton(
|
||||||
|
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
|
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
icon = action.icon,
|
||||||
|
height = 48.dp,
|
||||||
|
onClick = action.onClick,
|
||||||
|
modifier = Modifier.optionalTestTag(action.testTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MediaDetailSecondaryActionUiModel.MarkAsWatched -> {
|
||||||
|
MediaActionButton(
|
||||||
|
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
|
iconColor = if (action.watched) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
icon = if (action.watched) {
|
||||||
|
Icons.Outlined.CheckCircle
|
||||||
|
} else {
|
||||||
|
Icons.Outlined.RadioButtonUnchecked
|
||||||
|
},
|
||||||
|
height = 48.dp,
|
||||||
|
onClick = action.onClick,
|
||||||
|
modifier = Modifier.optionalTestTag(action.testTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.optionalTestTag(testTag: String?): Modifier =
|
||||||
|
if (testTag == null) this else then(Modifier.testTag(testTag))
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.common.media
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MediaHero(
|
|
||||||
imageUrl: String,
|
|
||||||
backgroundColor: Color,
|
|
||||||
heightFraction: Float = 0.4f,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
|
||||||
val heroHeight = screenHeight * heightFraction
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.height(heroHeight)
|
|
||||||
.background(backgroundColor)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
backgroundColor.copy(alpha = 0.5f),
|
|
||||||
backgroundColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package hu.bbara.purefin.ui.common.permission
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberNotificationPermissionGate(): (action: () -> Unit) -> Unit {
|
||||||
|
val pendingAction = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) {
|
||||||
|
// Proceed regardless — notification permission is nice-to-have for downloads.
|
||||||
|
val action = pendingAction.value
|
||||||
|
pendingAction.value = null
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
return remember(launcher) {
|
||||||
|
{ action ->
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
pendingAction.value = action
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package hu.bbara.purefin.ui.model
|
||||||
|
|
||||||
|
data class MediaAction(
|
||||||
|
val name: String,
|
||||||
|
val onClick: () -> Unit
|
||||||
|
)
|
||||||
@@ -9,30 +9,45 @@ import androidx.compose.material3.NavigationBar
|
|||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBottomBar(
|
fun AppBottomBar(
|
||||||
selectedTab: Int,
|
selectedTab: Int,
|
||||||
|
isOnline: Boolean,
|
||||||
onTabSelected: (Int) -> Unit
|
onTabSelected: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
NavigationBar {
|
NavigationBar(
|
||||||
|
modifier = Modifier.testTag(BottomNavigationTag)
|
||||||
|
) {
|
||||||
|
if (isOnline) {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == 0,
|
||||||
|
onClick = { onTabSelected(0) },
|
||||||
|
modifier = Modifier.testTag(BottomNavigationHomeTag),
|
||||||
|
icon = { Icon(Icons.Outlined.Home, contentDescription = "Home") },
|
||||||
|
label = { Text("Home") }
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == 1,
|
||||||
|
onClick = { onTabSelected(1) },
|
||||||
|
modifier = Modifier.testTag(BottomNavigationLibrariesTag),
|
||||||
|
icon = { Icon(Icons.Outlined.Collections, contentDescription = "Libraries") },
|
||||||
|
label = { Text("Libraries") }
|
||||||
|
)
|
||||||
|
}
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 0,
|
selected = !isOnline || selectedTab == 2,
|
||||||
onClick = { onTabSelected(0) },
|
|
||||||
icon = { Icon(Icons.Outlined.Home, contentDescription = "Home") },
|
|
||||||
label = { Text("Home") }
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedTab == 1,
|
|
||||||
onClick = { onTabSelected(1) },
|
|
||||||
icon = { Icon(Icons.Outlined.Collections, contentDescription = "Libraries") },
|
|
||||||
label = { Text("Libraries") }
|
|
||||||
)
|
|
||||||
NavigationBarItem(
|
|
||||||
selected = selectedTab == 2,
|
|
||||||
onClick = { onTabSelected(2) },
|
onClick = { onTabSelected(2) },
|
||||||
|
modifier = Modifier.testTag(BottomNavigationDownloadsTag),
|
||||||
icon = { Icon(Icons.Outlined.Download, contentDescription = "Downloads") },
|
icon = { Icon(Icons.Outlined.Download, contentDescription = "Downloads") },
|
||||||
label = { Text("Downloads") }
|
label = { Text("Downloads") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val BottomNavigationTag = "bottom-navigation"
|
||||||
|
internal const val BottomNavigationHomeTag = "bottom-navigation-home"
|
||||||
|
internal const val BottomNavigationLibrariesTag = "bottom-navigation-libraries"
|
||||||
|
internal const val BottomNavigationDownloadsTag = "bottom-navigation-downloads"
|
||||||
|
|||||||
@@ -1,59 +1,67 @@
|
|||||||
package hu.bbara.purefin.ui.screen
|
package hu.bbara.purefin.ui.screen
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.ContentTransform
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.scene.Scene
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import hu.bbara.purefin.feature.browse.home.AppViewModel
|
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
|
||||||
|
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
||||||
|
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
|
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
|
||||||
import hu.bbara.purefin.ui.screen.home.HomeScreen
|
import hu.bbara.purefin.ui.screen.home.HomeScreen
|
||||||
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
|
|
||||||
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
|
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppScreen(
|
fun AppScreen(
|
||||||
viewModel: AppViewModel = hiltViewModel(),
|
viewModel: AppViewModel = hiltViewModel(),
|
||||||
|
updateViewModel: AppUpdateViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val libraries by viewModel.libraries.collectAsState()
|
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
|
||||||
val libraryContent by viewModel.latestLibraryContent.collectAsState()
|
val libraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
|
||||||
val suggestions by viewModel.suggestions.collectAsState()
|
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
|
||||||
val nextUp by viewModel.nextUp.collectAsState()
|
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||||
|
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||||
|
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
|
||||||
|
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
|
||||||
|
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||||
val navigationManager = LocalNavigationManager.current
|
val navigationManager = LocalNavigationManager.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
|
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
|
||||||
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
|
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
|
||||||
val selectedTab = currentRoute.toTabIndex()
|
val selectedTab = currentRoute.toTabIndex()
|
||||||
|
|
||||||
val libraryNavItems = libraries.map {
|
LaunchedEffect(isOnline, currentRoute, backStack) {
|
||||||
HomeNavItem(
|
if (!isOnline && currentRoute != AppTabRoute.Downloads) {
|
||||||
id = it.id,
|
backStack.clear()
|
||||||
label = it.name,
|
backStack.add(AppTabRoute.Downloads)
|
||||||
posterUrl = it.posterUrl
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
@@ -61,9 +69,19 @@ fun AppScreen(
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
val onTabSelected = remember(backStack) {
|
LaunchedEffect(updateViewModel) {
|
||||||
|
updateViewModel.checkForUpdatesOnAppOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(updateViewModel) {
|
||||||
|
updateViewModel.snackbarMessages.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onTabSelected = remember(backStack, isOnline) {
|
||||||
{ selectedIndex: Int ->
|
{ selectedIndex: Int ->
|
||||||
val route = selectedIndex.toAppTabRoute()
|
val route = if (isOnline) selectedIndex.toAppTabRoute() else AppTabRoute.Downloads
|
||||||
if (backStack.lastOrNull() != route) {
|
if (backStack.lastOrNull() != route) {
|
||||||
backStack.add(route)
|
backStack.add(route)
|
||||||
}
|
}
|
||||||
@@ -87,22 +105,27 @@ fun AppScreen(
|
|||||||
library.name
|
library.name
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onMarkWatched = viewModel::markAsWatched,
|
||||||
onProfileClick = {},
|
onProfileClick = {},
|
||||||
onSettingsClick = {},
|
onCheckForUpdates = { updateViewModel.checkForUpdates() },
|
||||||
|
isCheckingForUpdates = isCheckingForUpdates,
|
||||||
|
onSettingsClick = viewModel::openSettings,
|
||||||
onLogoutClick = viewModel::logout,
|
onLogoutClick = viewModel::logout,
|
||||||
|
onSearchClick = viewModel::openSearch,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
|
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
|
||||||
LibrariesScreen(
|
LibrariesScreen(
|
||||||
items = libraryNavItems,
|
items = libraries,
|
||||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
|
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) },
|
||||||
onProfileClick = {},
|
onSearchClick = viewModel::openSearch,
|
||||||
onSettingsClick = {},
|
|
||||||
onLogoutClick = viewModel::logout,
|
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@@ -110,12 +133,19 @@ fun AppScreen(
|
|||||||
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
|
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
|
||||||
DownloadsScreen(
|
DownloadsScreen(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
|
isCheckingConnection = isCheckingConnection,
|
||||||
|
onCheckConnection = viewModel::checkConnection,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size == 1) {
|
||||||
|
navigationManager.pop()
|
||||||
|
}
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -128,12 +158,20 @@ fun AppScreen(
|
|||||||
entryDecorators = listOf(
|
entryDecorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator()
|
rememberSaveableStateHolderNavEntryDecorator()
|
||||||
),
|
),
|
||||||
transitionSpec = { appTabTransition() },
|
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||||
popTransitionSpec = { appTabTransition() },
|
popTransitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||||
predictivePopTransitionSpec = { _ -> appTabTransition() },
|
predictivePopTransitionSpec = { _ -> fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||||
entryProvider = tabEntryProvider,
|
entryProvider = tabEntryProvider,
|
||||||
modifier = modifier.fillMaxSize()
|
modifier = modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
availableUpdate?.let { update ->
|
||||||
|
UpdateAvailableDialog(
|
||||||
|
update = update,
|
||||||
|
onAccept = updateViewModel::acceptUpdate,
|
||||||
|
onDecline = updateViewModel::declineUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -161,31 +199,41 @@ private fun Int.toAppTabRoute(): AppTabRoute = when (this) {
|
|||||||
else -> AppTabRoute.Home
|
else -> AppTabRoute.Home
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AnimatedContentTransitionScope<Scene<AppTabRoute>>.appTabTransition(): ContentTransform {
|
|
||||||
val initialIndex = initialState.metadata.appTabIndex()
|
|
||||||
val targetIndex = targetState.metadata.appTabIndex()
|
|
||||||
val animationSpec = tween<IntOffset>(durationMillis = 140)
|
|
||||||
|
|
||||||
return when {
|
|
||||||
targetIndex > initialIndex -> {
|
|
||||||
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth } togetherWith
|
|
||||||
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth }
|
|
||||||
}
|
|
||||||
targetIndex < initialIndex -> {
|
|
||||||
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth } togetherWith
|
|
||||||
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
slideInHorizontally(animationSpec = animationSpec) { 0 } togetherWith
|
|
||||||
slideOutHorizontally(animationSpec = animationSpec) { 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
|
private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
|
||||||
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())
|
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())
|
||||||
|
|
||||||
private fun Map<String, Any>.appTabIndex(): Int =
|
private fun Map<String, Any>.appTabIndex(): Int =
|
||||||
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
|
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateAvailableDialog(
|
||||||
|
update: AppUpdateInfo,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit
|
||||||
|
) {
|
||||||
|
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||||
|
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||||
|
val updateText = if (releaseNotes == null) {
|
||||||
|
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||||
|
} else {
|
||||||
|
"Purefin $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDecline,
|
||||||
|
title = { Text("Update available") },
|
||||||
|
text = { Text(updateText) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onAccept) {
|
||||||
|
Text("Install")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDecline) {
|
||||||
|
Text("Not now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private const val APP_TAB_INDEX_METADATA = "app_tab_index"
|
private const val APP_TAB_INDEX_METADATA = "app_tab_index"
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ package hu.bbara.purefin.ui.screen.download
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
|
|
||||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||||
|
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadsScreen(
|
fun DownloadsScreen(
|
||||||
selectedTab: Int,
|
selectedTab: Int,
|
||||||
|
isOnline: Boolean,
|
||||||
|
isCheckingConnection: Boolean,
|
||||||
|
onCheckConnection: () -> Unit,
|
||||||
onTabSelected: (Int) -> Unit,
|
onTabSelected: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -19,9 +26,23 @@ fun DownloadsScreen(
|
|||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
topBar = {
|
||||||
|
DefaultTopBar(
|
||||||
|
rightActions = {
|
||||||
|
if (!isOnline) {
|
||||||
|
DefaultTopBarTextButton(
|
||||||
|
text = if (isCheckingConnection) "Checking…" else "Check connection",
|
||||||
|
onClick = onCheckConnection,
|
||||||
|
enabled = !isCheckingConnection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(
|
AppBottomBar(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
onTabSelected = onTabSelected
|
onTabSelected = onTabSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,20 +22,22 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.feature.downloads.ActiveDownloadItem
|
import hu.bbara.purefin.core.feature.downloads.ActiveDownloadItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DownloadingItemRow(
|
internal fun DownloadingItemRow(
|
||||||
item: ActiveDownloadItem,
|
item: ActiveDownloadItem,
|
||||||
onCancel: (String) -> Unit,
|
onCancel: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
@@ -91,7 +93,10 @@ internal fun DownloadingItemRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onCancel(item.contentId) }) {
|
IconButton(
|
||||||
|
onClick = { onCancel(item.contentId) },
|
||||||
|
modifier = Modifier.testTag("$DownloadsCancelButtonTagPrefix${item.contentId}")
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Close,
|
imageVector = Icons.Outlined.Close,
|
||||||
contentDescription = "Cancel download",
|
contentDescription = "Cancel download",
|
||||||
@@ -101,3 +106,5 @@ internal fun DownloadingItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val DownloadsCancelButtonTagPrefix = "downloads-cancel-button-"
|
||||||
|
|||||||
@@ -5,40 +5,46 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Download
|
import androidx.compose.material.icons.outlined.Download
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel
|
||||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||||
import hu.bbara.purefin.feature.downloads.DownloadsViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadsContent(
|
fun DownloadsContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: DownloadsViewModel = hiltViewModel(),
|
viewModel: DownloadsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val downloads = viewModel.downloads.collectAsState(emptyList())
|
val downloads = viewModel.downloads.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val activeDownloads = viewModel.activeDownloads.collectAsState()
|
val activeDownloads = viewModel.activeDownloads.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
|
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
|
||||||
|
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.testTag(DownloadsEmptyStateTag),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -62,7 +68,9 @@ fun DownloadsContent(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = modifier.background(MaterialTheme.colorScheme.background)
|
modifier = modifier
|
||||||
|
.testTag(DownloadsGridTag)
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
) {
|
) {
|
||||||
if (activeDownloads.value.isNotEmpty()) {
|
if (activeDownloads.value.isNotEmpty()) {
|
||||||
item(key = "downloading-header", span = { GridItemSpan(maxLineSpan) }) {
|
item(key = "downloading-header", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
@@ -80,7 +88,8 @@ fun DownloadsContent(
|
|||||||
) { item ->
|
) { item ->
|
||||||
DownloadingItemRow(
|
DownloadingItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
onCancel = { viewModel.cancelDownload(it) }
|
onCancel = { viewModel.cancelDownload(it) },
|
||||||
|
modifier = Modifier.testTag("$DownloadsActiveItemTagPrefix${item.contentId}")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (downloads.value.isNotEmpty()) {
|
if (downloads.value.isNotEmpty()) {
|
||||||
@@ -95,13 +104,22 @@ fun DownloadsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(downloads.value, key = { item -> item.id }) { item ->
|
items(downloads.value, key = { item -> item.media.id }) { item ->
|
||||||
PosterCard(
|
Column(
|
||||||
item = item,
|
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
|
||||||
onMovieSelected = viewModel::onMovieSelected,
|
) {
|
||||||
onSeriesSelected = viewModel::onSeriesSelected,
|
PosterCard(
|
||||||
onEpisodeSelected = { _, _, _ -> },
|
item = item.media,
|
||||||
)
|
onMovieSelected = viewModel::onMovieSelected,
|
||||||
|
onSeriesSelected = viewModel::onSeriesSelected,
|
||||||
|
onEpisodeSelected = { _, _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val DownloadsEmptyStateTag = "downloads-empty-state"
|
||||||
|
internal const val DownloadsGridTag = "downloads-grid"
|
||||||
|
internal const val DownloadsActiveItemTagPrefix = "downloads-active-item-"
|
||||||
|
internal const val DownloadsItemTagPrefix = "downloads-item-"
|
||||||
|
|||||||
@@ -1,52 +1,44 @@
|
|||||||
package hu.bbara.purefin.ui.screen.episode
|
package hu.bbara.purefin.ui.screen.episode
|
||||||
|
|
||||||
import android.Manifest
|
import android.content.Intent
|
||||||
import android.os.Build
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import hu.bbara.purefin.ui.common.media.MediaHero
|
import hu.bbara.purefin.core.download.DownloadState
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||||
import hu.bbara.purefin.download.DownloadState
|
import hu.bbara.purefin.core.image.ArtworkKind
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.navigation.EpisodeDto
|
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.navigation.Route
|
|
||||||
import hu.bbara.purefin.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
|
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
|
import hu.bbara.purefin.player.PlayerActivity
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
|
||||||
|
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||||
|
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||||
|
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
|
||||||
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
|
||||||
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
|
||||||
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodeWatchedButtonTag
|
||||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
|
||||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
|
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
|
||||||
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -59,28 +51,18 @@ fun EpisodeScreen(
|
|||||||
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
||||||
|
|
||||||
LaunchedEffect(episode) {
|
LaunchedEffect(episode) {
|
||||||
viewModel.selectEpisode(
|
viewModel.selectEpisode(episode)
|
||||||
seriesId = episode.seriesId,
|
|
||||||
seasonId = episode.seasonId,
|
|
||||||
episodeId = episode.id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val episode = viewModel.episode.collectAsState()
|
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||||
val downloadState = viewModel.downloadState.collectAsState()
|
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||||
|
val requestNotificationPermission = rememberNotificationPermissionGate()
|
||||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { _ ->
|
|
||||||
// Proceed with download regardless — notification is nice-to-have
|
|
||||||
viewModel.onDownloadClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
val onDownloadClick = {
|
val onDownloadClick = {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
if (downloadState.value is DownloadState.NotDownloaded) {
|
||||||
&& downloadState.value is DownloadState.NotDownloaded
|
requestNotificationPermission {
|
||||||
) {
|
viewModel.onDownloadClick()
|
||||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.onDownloadClick()
|
viewModel.onDownloadClick()
|
||||||
}
|
}
|
||||||
@@ -101,121 +83,94 @@ fun EpisodeScreen(
|
|||||||
},
|
},
|
||||||
downloadState = downloadState.value,
|
downloadState = downloadState.value,
|
||||||
onBack = viewModel::onBack,
|
onBack = viewModel::onBack,
|
||||||
onSeriesClick = viewModel::onSeriesClick,
|
|
||||||
onDownloadClick = onDownloadClick,
|
onDownloadClick = onDownloadClick,
|
||||||
|
onMarkAsWatched = viewModel::markAsWatched,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeScreenInternal(
|
private fun EpisodeScreenInternal(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
topBarShortcut: EpisodeTopBarShortcut?,
|
topBarShortcut: EpisodeTopBarShortcut?,
|
||||||
downloadState: DownloadState,
|
downloadState: DownloadState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSeriesClick: () -> Unit,
|
|
||||||
onDownloadClick: () -> Unit,
|
onDownloadClick: () -> Unit,
|
||||||
|
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val playAction = remember(episode.id) {
|
||||||
|
{
|
||||||
|
val intent = Intent(context, PlayerActivity::class.java)
|
||||||
|
intent.putExtra("MEDIA_ID", episode.id.toString())
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
MediaDetailScaffold(
|
||||||
|
uiModel = episode.toMediaDetailScaffoldUiModel(
|
||||||
|
onPlayClick = playAction,
|
||||||
|
downloadState = downloadState,
|
||||||
|
onDownloadClick = onDownloadClick,
|
||||||
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
topBar = { scrollBehavior ->
|
||||||
topBar = {
|
|
||||||
EpisodeTopBar(
|
EpisodeTopBar(
|
||||||
shortcut = topBarShortcut,
|
shortcut = topBarShortcut,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSeriesClick = onSeriesClick,
|
scrollBehavior = scrollBehavior
|
||||||
modifier = Modifier
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
EpisodeHeroSection(episode = episode)
|
|
||||||
EpisodeDetails(
|
|
||||||
episode = episode,
|
|
||||||
downloadState = downloadState,
|
|
||||||
onDownloadClick = onDownloadClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EpisodeHeroSection(
|
|
||||||
episode: Episode,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
|
||||||
val sectionHeight = screenHeight * 0.4f
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(sectionHeight)
|
|
||||||
) {
|
|
||||||
MediaHero(
|
|
||||||
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
|
||||||
backgroundColor = scheme.background,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Transparent,
|
|
||||||
scheme.background.copy(alpha = 0.5f),
|
|
||||||
scheme.background
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = episode.title,
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 32.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
lineHeight = 38.sp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Episode ${episode.index}",
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
EpisodeMetaChips(episode = episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EpisodeMetaChips(episode: Episode) {
|
|
||||||
MediaMetadataFlowRow(
|
|
||||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
|
||||||
highlightedItem = episode.format
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Episode.toMediaDetailScaffoldUiModel(
|
||||||
|
onPlayClick: () -> Unit,
|
||||||
|
downloadState: DownloadState,
|
||||||
|
onDownloadClick: () -> Unit,
|
||||||
|
onMarkAsWatched: (Boolean) -> Unit,
|
||||||
|
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
|
||||||
|
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||||
|
title = title,
|
||||||
|
subtitle = "Episode $index",
|
||||||
|
metadataItems = listOf(releaseDate, rating, runtime, format),
|
||||||
|
highlightedMetadataItem = format,
|
||||||
|
actions = MediaDetailActionsUiModel(
|
||||||
|
primaryAction = MediaDetailPrimaryActionUiModel(
|
||||||
|
text = mediaPlayButtonText(progress, watched),
|
||||||
|
progress = mediaPlaybackProgress(progress),
|
||||||
|
onClick = onPlayClick,
|
||||||
|
testTag = EpisodePlayButtonTag
|
||||||
|
),
|
||||||
|
secondaryActions = listOf(
|
||||||
|
MediaDetailSecondaryActionUiModel.MarkAsWatched(
|
||||||
|
watched = watched,
|
||||||
|
onClick = { onMarkAsWatched(!watched) },
|
||||||
|
testTag = EpisodeWatchedButtonTag
|
||||||
|
),
|
||||||
|
MediaDetailSecondaryActionUiModel.Download(
|
||||||
|
downloadState = downloadState,
|
||||||
|
onClick = onDownloadClick,
|
||||||
|
testTag = EpisodeDownloadButtonTag
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dividerThickness = 2.dp
|
||||||
|
),
|
||||||
|
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
|
||||||
|
playbackSettings = MediaDetailPlaybackSettingsUiModel(
|
||||||
|
audioTrack = "ENG",
|
||||||
|
subtitles = "ENG"
|
||||||
|
),
|
||||||
|
cast = if (cast.isNotEmpty()) {
|
||||||
|
MediaDetailCastUiModel(members = cast)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeScreenPreview() {
|
private fun EpisodeScreenPreview() {
|
||||||
@@ -225,7 +180,6 @@ private fun EpisodeScreenPreview() {
|
|||||||
topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}),
|
topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}),
|
||||||
downloadState = DownloadState.Downloading(progressPercent = 0.42f),
|
downloadState = DownloadState.Downloading(progressPercent = 0.42f),
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSeriesClick = {},
|
|
||||||
onDownloadClick = {}
|
onDownloadClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,7 +191,9 @@ private fun previewEpisode(): Episode {
|
|||||||
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 that reframes everything they thought they understood.",
|
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue that reframes everything they thought they understood.",
|
||||||
|
|||||||
@@ -1,49 +1,27 @@
|
|||||||
package hu.bbara.purefin.ui.screen.episode.components
|
package hu.bbara.purefin.ui.screen.episode.components
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.ArrowBack
|
import androidx.compose.material.icons.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Cast
|
import androidx.compose.material.icons.outlined.Cast
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.material.icons.outlined.Download
|
|
||||||
import androidx.compose.material.icons.outlined.DownloadDone
|
|
||||||
import androidx.compose.material.icons.outlined.MoreVert
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.download.DownloadState
|
|
||||||
import hu.bbara.purefin.model.Episode
|
|
||||||
import hu.bbara.purefin.player.PlayerActivity
|
|
||||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||||
import hu.bbara.purefin.ui.common.button.MediaActionButton
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaSynopsis
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
|
||||||
|
|
||||||
internal sealed interface EpisodeTopBarShortcut {
|
internal sealed interface EpisodeTopBarShortcut {
|
||||||
val label: String
|
val label: String
|
||||||
@@ -54,137 +32,60 @@ internal sealed interface EpisodeTopBarShortcut {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EpisodeTopBar(
|
internal fun EpisodeTopBar(
|
||||||
shortcut: EpisodeTopBarShortcut?,
|
shortcut: EpisodeTopBarShortcut?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSeriesClick: () -> Unit,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
DefaultTopBar(
|
||||||
Row(
|
leftActions = {
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
GhostIconButton(
|
GhostIconButton(
|
||||||
icon = Icons.Outlined.ArrowBack,
|
icon = Icons.Outlined.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
onClick = onBack
|
onClick = onBack
|
||||||
)
|
)
|
||||||
when {
|
when (shortcut) {
|
||||||
shortcut != null -> {
|
is EpisodeTopBarShortcut.Series -> {
|
||||||
Box(
|
TextButton(
|
||||||
|
onClick = shortcut.onClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(52.dp)
|
.height(52.dp)
|
||||||
.clickable(onClick = shortcut.onClick),
|
.testTag(EpisodeSeriesButtonTag)
|
||||||
contentAlignment = Alignment.Center
|
.clip(CircleShape)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = shortcut.label,
|
text = shortcut.label,
|
||||||
color = scheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
fontSize = 16.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(scheme.background.copy(alpha = 0.65f))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
null -> {}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
rightActions = {
|
||||||
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
|
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
|
||||||
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
|
GhostIconButton(
|
||||||
}
|
icon = Icons.Outlined.MoreVert,
|
||||||
}
|
contentDescription = "More",
|
||||||
|
onClick = { })
|
||||||
|
},
|
||||||
|
withIcon = false,
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
internal const val EpisodeSeriesButtonTag = "episode-series-button"
|
||||||
internal fun EpisodeDetails(
|
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||||
episode: Episode,
|
internal const val EpisodeDownloadButtonTag = "episode-download-button"
|
||||||
downloadState: DownloadState,
|
internal const val EpisodeWatchedButtonTag = "episode-watched-button"
|
||||||
onDownloadClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val playAction = remember(episode.id) {
|
|
||||||
{
|
|
||||||
val intent = Intent(context, PlayerActivity::class.java)
|
|
||||||
intent.putExtra("MEDIA_ID", episode.id.toString())
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
MediaSynopsis(
|
|
||||||
synopsis = episode.synopsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Row() {
|
|
||||||
MediaResumeButton(
|
|
||||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
|
||||||
progress = mediaPlaybackProgress(episode.progress),
|
|
||||||
onClick = playAction,
|
|
||||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
|
||||||
)
|
|
||||||
VerticalDivider(
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
thickness = 4.dp,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(48.dp)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
Row() {
|
|
||||||
MediaActionButton(
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
iconColor = MaterialTheme.colorScheme.onSecondary,
|
|
||||||
icon = when (downloadState) {
|
|
||||||
is DownloadState.NotDownloaded -> Icons.Outlined.Download
|
|
||||||
is DownloadState.Downloading -> Icons.Outlined.Close
|
|
||||||
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
|
|
||||||
is DownloadState.Failed -> Icons.Outlined.Download
|
|
||||||
},
|
|
||||||
height = 48.dp,
|
|
||||||
onClick = onDownloadClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
MediaPlaybackSettings(
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
|
||||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
//TODO fix it
|
|
||||||
audioTrack = "ENG",
|
|
||||||
subtitles = "ENG"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (episode.cast.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "Cast",
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
//TODO use it
|
|
||||||
// MediaCastRow(
|
|
||||||
// cast = episode.cast
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home
|
package hu.bbara.purefin.ui.screen.home
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||||
import hu.bbara.purefin.ui.screen.home.components.HomeContent
|
import hu.bbara.purefin.ui.screen.home.components.HomeContent
|
||||||
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
|
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
|
||||||
import hu.bbara.purefin.ui.screen.home.components.search.HomeSearchOverlay
|
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryUiModel>,
|
||||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||||
suggestions: List<MediaUiModel>,
|
suggestions: List<MediaUiModel>,
|
||||||
continueWatching: List<MediaUiModel>,
|
continueWatching: List<MediaUiModel>,
|
||||||
@@ -31,67 +29,61 @@ fun HomeScreen(
|
|||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onLibrarySelected: (LibraryItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
|
onMarkWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
onProfileClick: () -> Unit,
|
onProfileClick: () -> Unit,
|
||||||
|
onCheckForUpdates: () -> Unit,
|
||||||
|
isCheckingForUpdates: Boolean,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onLogoutClick: () -> Unit,
|
onLogoutClick: () -> Unit,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
selectedTab: Int,
|
selectedTab: Int,
|
||||||
|
isOnline: Boolean,
|
||||||
onTabSelected: (Int) -> Unit,
|
onTabSelected: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var isSearchVisible by rememberSaveable { mutableStateOf(false) }
|
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize()
|
||||||
|
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
topBar = {
|
topBar = {
|
||||||
HomeTopBar(
|
HomeTopBar(
|
||||||
onSearchClick = { isSearchVisible = true },
|
onSearchClick = onSearchClick,
|
||||||
onProfileClick = onProfileClick,
|
onProfileClick = onProfileClick,
|
||||||
|
onCheckForUpdates = onCheckForUpdates,
|
||||||
|
isCheckingForUpdates = isCheckingForUpdates,
|
||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
onLogoutClick = onLogoutClick
|
onLogoutClick = onLogoutClick,
|
||||||
|
scrollBehavior = topBarScrollBehavior
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(
|
AppBottomBar(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
onTabSelected = onTabSelected
|
onTabSelected = onTabSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
HomeContent(
|
||||||
HomeContent(
|
libraries = libraries,
|
||||||
libraries = libraries,
|
libraryContent = libraryContent,
|
||||||
libraryContent = libraryContent,
|
suggestions = suggestions,
|
||||||
suggestions = suggestions,
|
continueWatching = continueWatching,
|
||||||
continueWatching = continueWatching,
|
nextUp = nextUp,
|
||||||
nextUp = nextUp,
|
isRefreshing = isRefreshing,
|
||||||
isRefreshing = isRefreshing,
|
onRefresh = onRefresh,
|
||||||
onRefresh = onRefresh,
|
onMediaSelected = onMediaSelected,
|
||||||
onMediaSelected = onMediaSelected,
|
onLibrarySelected = onLibrarySelected,
|
||||||
onLibrarySelected = onLibrarySelected,
|
onBrowseLibrariesClick = { onTabSelected(1) },
|
||||||
onBrowseLibrariesClick = { onTabSelected(1) },
|
onMarkAsWatched = onMarkWatched,
|
||||||
modifier = Modifier.padding(innerPadding)
|
modifier = Modifier.padding(innerPadding)
|
||||||
)
|
)
|
||||||
HomeSearchOverlay(
|
|
||||||
visible = isSearchVisible,
|
|
||||||
topPadding = innerPadding.calculateTopPadding(),
|
|
||||||
onDismiss = { isSearchVisible = false },
|
|
||||||
onMovieSelected = {
|
|
||||||
isSearchVisible = false
|
|
||||||
//TODO use MediaUiModel as well
|
|
||||||
//onMovieSelected(it)
|
|
||||||
},
|
|
||||||
onSeriesSelected = {
|
|
||||||
isSearchVisible = false
|
|
||||||
//onSeriesSelected(it)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
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.outlined.Search
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonColors
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
|
||||||
|
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||||
|
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||||
|
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberDefaultTopBarScrollBehavior(): TopAppBarScrollBehavior =
|
||||||
|
TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DefaultTopBar(
|
||||||
|
leftActions: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
rightActions: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
withIcon: Boolean = true,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
if (withIcon) {
|
||||||
|
PurefinLogo(
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (leftActions != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
content = leftActions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (rightActions != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
content = rightActions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
scrolledContainerColor = scheme.background,
|
||||||
|
navigationIconContentColor = scheme.onSurface,
|
||||||
|
titleContentColor = scheme.onSurface,
|
||||||
|
actionIconContentColor = scheme.onSurface
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultTopBarIconButton(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = IconButtonColors(
|
||||||
|
containerColor = scheme.surface,
|
||||||
|
contentColor = scheme.onSurface,
|
||||||
|
disabledContainerColor = scheme.surface,
|
||||||
|
disabledContentColor = scheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(50.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVector,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.size(30.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultTopBarTextButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
containerColor = scheme.surface,
|
||||||
|
contentColor = scheme.onSurface,
|
||||||
|
disabledContainerColor = scheme.surface,
|
||||||
|
disabledContentColor = scheme.onSurface
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(horizontal = 18.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.height(50.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DefaultTopBarSearchButton(
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
|
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
|
||||||
|
|
||||||
|
val searchButtonModifier = if (
|
||||||
|
sharedTransitionScope != null &&
|
||||||
|
animatedVisibilityScope != null
|
||||||
|
) {
|
||||||
|
with(sharedTransitionScope) {
|
||||||
|
Modifier
|
||||||
|
.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(
|
||||||
|
key = HOME_SEARCH_SHARED_BOUNDS_KEY
|
||||||
|
),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
|
||||||
|
)
|
||||||
|
.size(50.dp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier.size(50.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = IconButtonColors(
|
||||||
|
containerColor = scheme.surface,
|
||||||
|
contentColor = scheme.onSurface,
|
||||||
|
disabledContainerColor = scheme.surface,
|
||||||
|
disabledContentColor = scheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = searchButtonModifier,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Search,
|
||||||
|
contentDescription = "Search",
|
||||||
|
modifier = Modifier.size(30.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
|
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
|
||||||
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
|
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
|
||||||
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
|
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
|
||||||
@@ -32,7 +33,7 @@ import java.util.UUID
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeContent(
|
fun HomeContent(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryUiModel>,
|
||||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||||
suggestions: List<MediaUiModel>,
|
suggestions: List<MediaUiModel>,
|
||||||
continueWatching: List<MediaUiModel>,
|
continueWatching: List<MediaUiModel>,
|
||||||
@@ -40,8 +41,9 @@ fun HomeContent(
|
|||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
onLibrarySelected: (LibraryItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
onBrowseLibrariesClick: () -> Unit,
|
onBrowseLibrariesClick: () -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -79,7 +81,9 @@ fun HomeContent(
|
|||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
modifier = modifier.fillMaxSize()
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.testTag(HomeContentTag)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -88,7 +92,9 @@ fun HomeContent(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.testTag(HomeContentViewportTag),
|
||||||
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
|
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
) {
|
) {
|
||||||
@@ -96,7 +102,8 @@ fun HomeContent(
|
|||||||
item(key = "featured") {
|
item(key = "featured") {
|
||||||
SuggestionsSection(
|
SuggestionsSection(
|
||||||
items = suggestions,
|
items = suggestions,
|
||||||
onItemOpen = { item -> onMediaSelected(item) }
|
onItemOpen = { item -> onMediaSelected(item) },
|
||||||
|
modifier = Modifier.testTag(HomeFeaturedSectionTag)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,17 +112,21 @@ fun HomeContent(
|
|||||||
item(key = "continue-watching") {
|
item(key = "continue-watching") {
|
||||||
ContinueWatchingSection(
|
ContinueWatchingSection(
|
||||||
items = continueWatching,
|
items = continueWatching,
|
||||||
onMediaSelected = onMediaSelected
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
onMediaSelected = onMediaSelected,
|
||||||
|
modifier = Modifier.testTag(HomeContinueWatchingSectionTag)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextUp.isNotEmpty()) {
|
if (nextUp.isNotEmpty()) {
|
||||||
item(key = "next-up") {
|
item(key = "next-up") {
|
||||||
NextUpSection(
|
NextUpSection(
|
||||||
items = nextUp,
|
items = nextUp,
|
||||||
onMediaSelected = onMediaSelected
|
onMediaSelected = onMediaSelected,
|
||||||
)
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
modifier = Modifier.testTag(HomeNextUpSectionTag)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +138,9 @@ fun HomeContent(
|
|||||||
library = library,
|
library = library,
|
||||||
items = libraryContent[library.id].orEmpty(),
|
items = libraryContent[library.id].orEmpty(),
|
||||||
onLibrarySelected = onLibrarySelected,
|
onLibrarySelected = onLibrarySelected,
|
||||||
onMediaSelected = onMediaSelected
|
onMediaSelected = onMediaSelected,
|
||||||
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +148,20 @@ fun HomeContent(
|
|||||||
item(key = "empty-state") {
|
item(key = "empty-state") {
|
||||||
HomeEmptyState(
|
HomeEmptyState(
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
onBrowseLibrariesClick = onBrowseLibrariesClick
|
onBrowseLibrariesClick = onBrowseLibrariesClick,
|
||||||
|
modifier = Modifier.testTag(HomeEmptyStateTag)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val HomeContentTag = "home-content"
|
||||||
|
internal const val HomeContentViewportTag = "home-content-viewport"
|
||||||
|
internal const val HomeFeaturedSectionTag = "home-section-featured"
|
||||||
|
internal const val HomeContinueWatchingSectionTag = "home-section-continue-watching"
|
||||||
|
internal const val HomeNextUpSectionTag = "home-section-next-up"
|
||||||
|
internal const val HomeLibrarySectionTagPrefix = "home-section-library-"
|
||||||
|
internal const val HomeEmptyStateTag = "home-empty-state"
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ 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.Collections
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
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.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -31,9 +32,9 @@ fun HomeEmptyState(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Surface(
|
Card(
|
||||||
shape = RoundedCornerShape(30.dp),
|
shape = RoundedCornerShape(30.dp),
|
||||||
color = scheme.surfaceContainerLow,
|
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainerLow),
|
||||||
modifier = modifier.padding(horizontal = 16.dp)
|
modifier = modifier.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
data class HomeNavItem(
|
|
||||||
val id: UUID,
|
|
||||||
val label: String,
|
|
||||||
val posterUrl: String,
|
|
||||||
val selected: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HomeUser(
|
|
||||||
val name: String,
|
|
||||||
val plan: String
|
|
||||||
)
|
|
||||||
@@ -1,105 +1,40 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components
|
package hu.bbara.purefin.ui.screen.home.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.Search
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.IconButtonColors
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeTopBar(
|
fun HomeTopBar(
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onProfileClick: () -> Unit,
|
onProfileClick: () -> Unit,
|
||||||
|
onCheckForUpdates: () -> Unit,
|
||||||
|
isCheckingForUpdates: Boolean,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
onLogoutClick: () -> Unit,
|
onLogoutClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
TopAppBar(
|
DefaultTopBar(
|
||||||
title = {
|
scrollBehavior = scrollBehavior,
|
||||||
Row(
|
rightActions = {
|
||||||
modifier = Modifier,
|
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
DefaultTopBarIconButton(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
imageVector = Icons.Outlined.Person,
|
||||||
) {
|
contentDescription = "Profile",
|
||||||
PurefinLogo(
|
|
||||||
contentDescription = "Purefin",
|
|
||||||
modifier = Modifier.size(48.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "PureFin",
|
|
||||||
fontSize = 32.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
color = scheme.onSecondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onSearchClick,
|
|
||||||
colors = IconButtonColors(
|
|
||||||
containerColor = scheme.secondary,
|
|
||||||
contentColor = scheme.onSecondary,
|
|
||||||
disabledContainerColor = scheme.secondary,
|
|
||||||
disabledContentColor = scheme.onSecondary),
|
|
||||||
modifier = Modifier.size(50.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Search,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(12.dp))
|
|
||||||
IconButton(
|
|
||||||
onClick = { isProfileMenuExpanded = true },
|
onClick = { isProfileMenuExpanded = true },
|
||||||
colors = IconButtonColors(
|
)
|
||||||
containerColor = scheme.secondary,
|
|
||||||
contentColor = scheme.onSecondary,
|
|
||||||
disabledContainerColor = scheme.secondary,
|
|
||||||
disabledContentColor = scheme.onSecondary),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(50.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Person,
|
|
||||||
contentDescription = "Profile",
|
|
||||||
modifier = Modifier.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = isProfileMenuExpanded,
|
expanded = isProfileMenuExpanded,
|
||||||
onDismissRequest = { isProfileMenuExpanded = false },
|
onDismissRequest = { isProfileMenuExpanded = false },
|
||||||
@@ -111,6 +46,14 @@ fun HomeTopBar(
|
|||||||
onProfileClick()
|
onProfileClick()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (isCheckingForUpdates) "Checking updates..." else "Check for updates") },
|
||||||
|
enabled = !isCheckingForUpdates,
|
||||||
|
onClick = {
|
||||||
|
isProfileMenuExpanded = false
|
||||||
|
onCheckForUpdates()
|
||||||
|
}
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Settings") },
|
text = { Text("Settings") },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -126,14 +69,6 @@ fun HomeTopBar(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
|
||||||
containerColor = scheme.background,
|
|
||||||
scrolledContainerColor = scheme.surface.copy(alpha = 0.96f),
|
|
||||||
navigationIconContentColor = scheme.onSurface,
|
|
||||||
actionIconContentColor = scheme.onSurface,
|
|
||||||
titleContentColor = scheme.onSurface
|
|
||||||
),
|
|
||||||
modifier = modifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,78 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.continuewatching
|
package hu.bbara.purefin.ui.screen.home.components.continuewatching
|
||||||
|
|
||||||
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.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||||
|
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||||
|
import hu.bbara.purefin.ui.model.MediaAction
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ContinueWatchingCard(
|
internal fun ContinueWatchingCard(
|
||||||
item: MediaUiModel,
|
item: MediaUiModel,
|
||||||
|
sharedBoundsKey: String,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||||
|
onMediaSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
MediaImageCard(
|
||||||
shape = RoundedCornerShape(26.dp),
|
imageUrl = item.primaryImageUrl,
|
||||||
color = scheme.surfaceContainer,
|
title = item.primaryText,
|
||||||
|
subtitle = item.secondaryText,
|
||||||
|
onClick = onClick,
|
||||||
|
popupActions = listOf(
|
||||||
|
MediaAction(
|
||||||
|
name = "Mark as watched",
|
||||||
|
onClick = { onMarkAsWatched(item, true) }
|
||||||
|
),
|
||||||
|
MediaAction(
|
||||||
|
name = "Mark as unwatched",
|
||||||
|
onClick = { onMarkAsWatched(item, false) }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
|
||||||
|
shapeSize = 26.dp,
|
||||||
|
imageAspectRatio = 16f / 9f,
|
||||||
|
titleStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
textPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
|
||||||
modifier = modifier.width(280.dp)
|
modifier = modifier.width(280.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.matchParentSize()
|
||||||
.clickable { onMediaSelected(item) }
|
.background(
|
||||||
) {
|
Brush.verticalGradient(
|
||||||
Box(
|
colors = listOf(
|
||||||
modifier = Modifier
|
Color.Transparent,
|
||||||
.fillMaxWidth()
|
Color.Black.copy(alpha = 0.08f),
|
||||||
.aspectRatio(16f / 9f)
|
Color.Black.copy(alpha = 0.38f)
|
||||||
.background(scheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = item.primaryImageUrl,
|
|
||||||
contentDescription = item.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.08f),
|
|
||||||
Color.Black.copy(alpha = 0.38f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
item.progress?.let { progress ->
|
|
||||||
MediaProgressBar(
|
|
||||||
progress = progress,
|
|
||||||
foregroundColor = scheme.primary,
|
|
||||||
backgroundColor = Color.White.copy(alpha = 0.24f),
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = item.primaryText,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Text(
|
)
|
||||||
text = item.secondaryText,
|
item.progress?.let { progress ->
|
||||||
style = MaterialTheme.typography.bodySmall,
|
MediaProgressBar(
|
||||||
color = scheme.onSurface,
|
progress = progress,
|
||||||
maxLines = 1,
|
foregroundColor = scheme.primary,
|
||||||
overflow = TextOverflow.Ellipsis
|
backgroundColor = Color.White.copy(alpha = 0.24f),
|
||||||
)
|
modifier = Modifier.align(Alignment.BottomStart)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ package hu.bbara.purefin.ui.screen.home.components.continuewatching
|
|||||||
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.ui.common.header.SectionHeader
|
import hu.bbara.purefin.ui.common.header.SectionHeader
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContinueWatchingSection(
|
fun ContinueWatchingSection(
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
@@ -33,18 +38,27 @@ fun ContinueWatchingSection(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
SectionHeader(title = "Continue Watching")
|
SectionHeader(title = "Continue Watching")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(HomeContinueWatchingRowTag),
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
items(items = items, key = { item -> item.id }) { item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
ContinueWatchingCard(
|
ContinueWatchingCard(
|
||||||
item = item,
|
item = item,
|
||||||
onMediaSelected = onMediaSelected
|
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
|
||||||
|
onMediaSelected = onMediaSelected,
|
||||||
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
modifier = Modifier.testTag("$HomeContinueWatchingItemTagPrefix$index")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val HomeContinueWatchingRowTag = "home-continue-watching-row"
|
||||||
|
internal const val HomeContinueWatchingItemTagPrefix = "home-continue-watching-item-"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.featured
|
package hu.bbara.purefin.ui.screen.home.components.featured
|
||||||
|
|
||||||
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
|
||||||
@@ -11,42 +10,49 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||||
|
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionCard(
|
internal fun SuggestionCard(
|
||||||
item: MediaUiModel,
|
item: MediaUiModel,
|
||||||
|
sharedBoundsKey: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val description = item.description.trim()
|
val description = item.description.trim()
|
||||||
|
val onCardClick = rememberHomeMediaSharedBoundsClick(
|
||||||
|
sharedBoundsKey = sharedBoundsKey,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
|
||||||
Surface(
|
ElevatedCard(
|
||||||
color = scheme.surfaceContainerLow,
|
onClick = onCardClick,
|
||||||
|
colors = CardDefaults.elevatedCardColors(containerColor = scheme.surfaceContainerLow),
|
||||||
|
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||||
shape = RoundedCornerShape(30.dp),
|
shape = RoundedCornerShape(30.dp),
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(30.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.homeMediaSharedBoundsSource(sharedBoundsKey)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(16f / 11f)
|
.aspectRatio(16f / 11f)
|
||||||
) {
|
) {
|
||||||
@@ -68,7 +74,7 @@ internal fun SuggestionCard(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SuggestionsSection(
|
fun SuggestionsSection(
|
||||||
@@ -47,11 +49,16 @@ fun SuggestionsSection(
|
|||||||
pageSize = PageSize.Fixed(320.dp),
|
pageSize = PageSize.Fixed(320.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
pageSpacing = 16.dp,
|
pageSpacing = 16.dp,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(HomeFeaturedPagerTag)
|
||||||
) { page ->
|
) { page ->
|
||||||
|
val item = items[page]
|
||||||
SuggestionCard(
|
SuggestionCard(
|
||||||
item = items[page],
|
item = item,
|
||||||
onClick = { onItemOpen(items[page]) }
|
sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id),
|
||||||
|
onClick = { onItemOpen(item) },
|
||||||
|
modifier = Modifier.testTag("$HomeFeaturedItemTagPrefix$page")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (items.size > 1) {
|
if (items.size > 1) {
|
||||||
@@ -83,3 +90,6 @@ fun SuggestionsSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val HomeFeaturedPagerTag = "home-featured-pager"
|
||||||
|
internal const val HomeFeaturedItemTagPrefix = "home-featured-item-"
|
||||||
|
|||||||
@@ -1,95 +1,70 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.library
|
package hu.bbara.purefin.ui.screen.home.components.library
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.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 hu.bbara.purefin.ui.model.EpisodeUiModel
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
|
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||||
|
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||||
|
import hu.bbara.purefin.ui.model.MediaAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HomeBrowseCard(
|
internal fun HomeBrowseCard(
|
||||||
uiModel: MediaUiModel,
|
uiModel: MediaUiModel,
|
||||||
|
sharedBoundsKey: String,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||||
|
onMediaSelected(uiModel)
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||||
shape = RoundedCornerShape(12.dp),
|
buildList {
|
||||||
color = scheme.surfaceContainer,
|
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||||
|
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||||
|
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaImageCard(
|
||||||
|
imageUrl = uiModel.primaryImageUrl,
|
||||||
|
title = uiModel.primaryText,
|
||||||
|
subtitle = uiModel.secondaryText,
|
||||||
|
onClick = onClick,
|
||||||
|
popupActions = popupActions,
|
||||||
|
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
|
||||||
|
imageAspectRatio = 15f / 16f,
|
||||||
modifier = modifier.width(188.dp)
|
modifier = modifier.width(188.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
when (uiModel) {
|
||||||
modifier = Modifier
|
is MovieUiModel, is EpisodeUiModel -> {
|
||||||
.fillMaxWidth()
|
WatchStateBadge(
|
||||||
.clickable { onMediaSelected(uiModel) }
|
size = 28,
|
||||||
) {
|
modifier = Modifier
|
||||||
Box(
|
.align(Alignment.TopEnd)
|
||||||
modifier = Modifier
|
.padding(8.dp),
|
||||||
.fillMaxWidth()
|
watched = uiModel.watched,
|
||||||
.aspectRatio(16f / 10f)
|
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
|
||||||
.background(scheme.surface)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = uiModel.primaryImageUrl,
|
|
||||||
contentDescription = uiModel.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
)
|
||||||
when (uiModel) {
|
|
||||||
is MovieUiModel, is EpisodeUiModel -> {
|
|
||||||
WatchStateBadge(
|
|
||||||
size = 28,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(8.dp),
|
|
||||||
watched = uiModel.watched,
|
|
||||||
started = (uiModel.progress ?: 0f) > 0f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
is SeriesUiModel -> {
|
||||||
Column(modifier = modifier.padding(12.dp)) {
|
UnwatchedEpisodeBadge(
|
||||||
Text(
|
unwatchedCount = uiModel.unwatchedEpisodeCount,
|
||||||
text = uiModel.primaryText,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
.align(Alignment.TopEnd)
|
||||||
fontWeight = FontWeight.SemiBold,
|
.padding(8.dp)
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = uiModel.secondaryText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = scheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,29 @@ package hu.bbara.purefin.ui.screen.home.components.library
|
|||||||
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
|
||||||
import hu.bbara.purefin.ui.common.header.SectionHeader
|
import hu.bbara.purefin.ui.common.header.SectionHeader
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryPosterSection(
|
fun LibraryPosterSection(
|
||||||
library: LibraryItem,
|
library: LibraryUiModel,
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onLibrarySelected: (LibraryItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
@@ -40,18 +45,30 @@ fun LibraryPosterSection(
|
|||||||
actionLabel = "See all",
|
actionLabel = "See all",
|
||||||
onActionClick = { onLibrarySelected(library) }
|
onActionClick = { onLibrarySelected(library) }
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("$HomeLibraryRowTagPrefix${library.id}"),
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
items(items = items, key = { item -> item.id }) { item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
HomeBrowseCard(
|
HomeBrowseCard(
|
||||||
uiModel = item,
|
uiModel = item,
|
||||||
onMediaSelected = onMediaSelected
|
sharedBoundsKey = homeMediaSharedBoundsKey(
|
||||||
|
origin = "library-${library.id}-$index",
|
||||||
|
mediaId = item.id
|
||||||
|
),
|
||||||
|
onMediaSelected = onMediaSelected,
|
||||||
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val HomeLibraryRowTagPrefix = "home-library-row-"
|
||||||
|
internal const val HomeLibraryItemTagPrefix = "home-library-item-"
|
||||||
|
|||||||
@@ -1,93 +1,70 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.nextup
|
package hu.bbara.purefin.ui.screen.home.components.nextup
|
||||||
|
|
||||||
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.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
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 hu.bbara.purefin.ui.model.MediaUiModel
|
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
|
||||||
|
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
|
||||||
|
import hu.bbara.purefin.ui.model.MediaAction
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
import hu.bbara.purefin.core.model.MovieUiModel
|
||||||
|
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||||
|
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun NextUpCard(
|
internal fun NextUpCard(
|
||||||
uiModel: MediaUiModel,
|
uiModel: MediaUiModel,
|
||||||
|
sharedBoundsKey: String,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||||
|
onMediaSelected(uiModel)
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||||
shape = RoundedCornerShape(24.dp),
|
buildList {
|
||||||
color = scheme.surfaceContainer,
|
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||||
modifier = modifier.width(256.dp)
|
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||||
) {
|
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onMediaSelected(uiModel) }
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(16f / 10f)
|
|
||||||
.background(scheme.surface)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = uiModel.primaryImageUrl,
|
|
||||||
contentDescription = uiModel.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.26f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = uiModel.primaryText,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = uiModel.secondaryText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = scheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaImageCard(
|
||||||
|
imageUrl = uiModel.primaryImageUrl,
|
||||||
|
title = uiModel.primaryText,
|
||||||
|
subtitle = uiModel.secondaryText,
|
||||||
|
onClick = onClick,
|
||||||
|
popupActions = popupActions,
|
||||||
|
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
|
||||||
|
shapeSize = 24.dp,
|
||||||
|
titleStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
textPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
modifier = modifier.width(256.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.26f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ package hu.bbara.purefin.ui.screen.home.components.nextup
|
|||||||
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.ui.common.header.SectionHeader
|
import hu.bbara.purefin.ui.common.header.SectionHeader
|
||||||
|
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||||
|
import hu.bbara.purefin.core.model.MediaUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NextUpSection(
|
fun NextUpSection(
|
||||||
items: List<MediaUiModel>,
|
items: List<MediaUiModel>,
|
||||||
onMediaSelected: (MediaUiModel) -> Unit,
|
onMediaSelected: (MediaUiModel) -> Unit,
|
||||||
|
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
@@ -33,18 +38,27 @@ fun NextUpSection(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
SectionHeader(title = "Next Up")
|
SectionHeader(title = "Next Up")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(HomeNextUpRowTag),
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
items(items = items, key = { item -> item.id }) { item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
NextUpCard(
|
NextUpCard(
|
||||||
uiModel = item,
|
uiModel = item,
|
||||||
onMediaSelected = onMediaSelected
|
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
|
||||||
|
onMediaSelected = onMediaSelected,
|
||||||
|
onMarkAsWatched = onMarkAsWatched,
|
||||||
|
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val HomeNextUpRowTag = "home-next-up-row"
|
||||||
|
internal const val HomeNextUpItemTagPrefix = "home-next-up-item-"
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
package hu.bbara.purefin.ui.screen.home.components.search
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import hu.bbara.purefin.model.MediaKind
|
|
||||||
import hu.bbara.purefin.feature.search.SearchResult
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun HomeSearchResultCard(
|
|
||||||
item: SearchResult,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(22.dp),
|
|
||||||
color = scheme.surfaceContainer,
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(22.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = item.posterUrl,
|
|
||||||
contentDescription = item.title,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(180.dp)
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = when (item.type) {
|
|
||||||
MediaKind.MOVIE -> "Movie"
|
|
||||||
MediaKind.SERIES -> "Series"
|
|
||||||
else -> "Title"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = scheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,8 @@ import androidx.compose.material3.SearchBar
|
|||||||
import androidx.compose.material3.SearchBarDefaults
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -35,13 +35,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.feature.search.SearchViewModel
|
import hu.bbara.purefin.core.feature.search.SearchViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import hu.bbara.purefin.model.MediaKind
|
import hu.bbara.purefin.model.MediaKind
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeSearchOverlay(
|
fun SearchOverlay(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
topPadding: Dp,
|
topPadding: Dp,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -55,7 +55,7 @@ fun HomeSearchOverlay(
|
|||||||
BackHandler(onBack = onDismiss)
|
BackHandler(onBack = onDismiss)
|
||||||
|
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
val searchResults by searchViewModel.searchResult.collectAsState()
|
val searchResults by searchViewModel.searchResult.collectAsStateWithLifecycle()
|
||||||
val dismissInteractionSource = remember { MutableInteractionSource() }
|
val dismissInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -138,7 +138,7 @@ fun HomeSearchOverlay(
|
|||||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||||
) {
|
) {
|
||||||
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
|
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
|
||||||
HomeSearchResultCard(
|
SearchResultCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = {
|
onClick = {
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package hu.bbara.purefin.ui.screen.home.components.search
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import hu.bbara.purefin.core.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.model.MediaKind
|
||||||
|
import hu.bbara.purefin.ui.common.card.MediaImageCard
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SearchResultCard(
|
||||||
|
item: SearchResult,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
MediaImageCard(
|
||||||
|
imageUrl = item.posterUrl,
|
||||||
|
title = item.title,
|
||||||
|
subtitle = when (item.type) {
|
||||||
|
MediaKind.MOVIE -> "Movie"
|
||||||
|
MediaKind.SERIES -> "Series"
|
||||||
|
else -> "Title"
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
package hu.bbara.purefin.ui.screen.home.components.search
|
||||||
|
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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.heightIn
|
||||||
|
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.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import hu.bbara.purefin.core.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.core.feature.search.SearchViewModel
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
|
import hu.bbara.purefin.model.MediaKind
|
||||||
|
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
|
||||||
|
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||||
|
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||||
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
|
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
|
||||||
|
val searchResults by viewModel.searchResult.collectAsStateWithLifecycle()
|
||||||
|
val genres by viewModel.genres.collectAsStateWithLifecycle()
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var selectedGenreNames by rememberSaveable { mutableStateOf(emptySet<String>()) }
|
||||||
|
val sharedBoundsModifier = if (
|
||||||
|
sharedTransitionScope != null &&
|
||||||
|
animatedVisibilityScope != null
|
||||||
|
) {
|
||||||
|
with(sharedTransitionScope) {
|
||||||
|
Modifier.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(
|
||||||
|
key = HOME_SEARCH_SHARED_BOUNDS_KEY
|
||||||
|
),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchScreenContent(
|
||||||
|
query = query,
|
||||||
|
searchResults = searchResults,
|
||||||
|
genres = genres.sortedBy { it.name },
|
||||||
|
selectedGenreNames = selectedGenreNames,
|
||||||
|
onQueryChange = {
|
||||||
|
query = it
|
||||||
|
viewModel.search(it)
|
||||||
|
},
|
||||||
|
onSearch = { viewModel.search(query) },
|
||||||
|
onResultClick = viewModel::onSearchResultSelected,
|
||||||
|
onGenreSelected = { genre ->
|
||||||
|
selectedGenreNames = if (genre.name in selectedGenreNames) {
|
||||||
|
selectedGenreNames - genre.name
|
||||||
|
} else {
|
||||||
|
selectedGenreNames + genre.name
|
||||||
|
}
|
||||||
|
viewModel.setSelectedGenres(selectedGenreNames)
|
||||||
|
},
|
||||||
|
onClearGenres = {
|
||||||
|
selectedGenreNames = emptySet()
|
||||||
|
viewModel.setSelectedGenres(selectedGenreNames)
|
||||||
|
},
|
||||||
|
onBack = viewModel::onBack,
|
||||||
|
modifier = modifier.then(sharedBoundsModifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchScreenContent(
|
||||||
|
query: String,
|
||||||
|
searchResults: List<SearchResult>,
|
||||||
|
genres: List<Genre>,
|
||||||
|
selectedGenreNames: Set<String>,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
onGenreSelected: (Genre) -> Unit,
|
||||||
|
onClearGenres: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = { SearchHeader(
|
||||||
|
onBack = onBack,
|
||||||
|
scrollBehavior = topBarScrollBehavior
|
||||||
|
) },
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.testTag(SearchScreenTag)
|
||||||
|
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||||
|
.background(scheme.background)
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 28.dp)
|
||||||
|
) {
|
||||||
|
SearchField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onSearch = onSearch
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(30.dp))
|
||||||
|
if (query.isBlank()) {
|
||||||
|
SectionTitle(text = "Browse Genres")
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
GenreSelector(
|
||||||
|
genres = genres,
|
||||||
|
selectedGenreNames = selectedGenreNames,
|
||||||
|
onGenreSelected = onGenreSelected,
|
||||||
|
onClearGenres = onClearGenres
|
||||||
|
)
|
||||||
|
if (selectedGenreNames.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(30.dp))
|
||||||
|
SectionTitle(text = "Search Results")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
SearchResults(
|
||||||
|
searchResults = searchResults,
|
||||||
|
onResultClick = onResultClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SectionTitle(text = "Search Results")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
SearchResults(
|
||||||
|
searchResults = searchResults,
|
||||||
|
onResultClick = onResultClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
searchResults: List<SearchResult>,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
SearchMessage(
|
||||||
|
title = "No matches",
|
||||||
|
body = "Try a different title or browse your libraries.",
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SearchResultsGrid(
|
||||||
|
results = searchResults,
|
||||||
|
onResultClick = onResultClick,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
private fun SearchHeader(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
) {
|
||||||
|
DefaultTopBar(
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
leftActions = {
|
||||||
|
DefaultTopBarIconButton(
|
||||||
|
imageVector = Icons.Outlined.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
onClick = onBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Search movies, shows, genres...",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = scheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(30.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onQueryChange("") },
|
||||||
|
modifier = Modifier.testTag(SearchClearButtonTag)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
tint = scheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = scheme.surfaceContainer,
|
||||||
|
unfocusedContainerColor = scheme.surfaceContainer,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
onSearch()
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.testTag(SearchQueryFieldTag)
|
||||||
|
.height(68.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultsGrid(
|
||||||
|
results: List<SearchResult>,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(SearchResultsGridTag)
|
||||||
|
) {
|
||||||
|
results.chunked(2).forEachIndexed { rowIndex, rowItems ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
rowItems.forEachIndexed { columnIndex, item ->
|
||||||
|
val itemIndex = rowIndex * 2 + columnIndex
|
||||||
|
SearchResultCard(
|
||||||
|
item = item,
|
||||||
|
onClick = { onResultClick(item) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.testTag("$SearchResultItemTagPrefix$itemIndex")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rowItems.size == 1) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: @Composable (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
icon?.invoke()
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun GenreSelector(
|
||||||
|
genres: List<Genre>,
|
||||||
|
selectedGenreNames: Set<String>,
|
||||||
|
onGenreSelected: (Genre) -> Unit,
|
||||||
|
onClearGenres: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var showGenres by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val visibleGenres = (genres.filter { it.name in selectedGenreNames } + genres)
|
||||||
|
.distinctBy { it.name }
|
||||||
|
.take(19)
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(260.dp)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = false,
|
||||||
|
onClick = { showGenres = true },
|
||||||
|
modifier = Modifier.testTag(SearchGenreSelectorTag),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Tune,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = if (selectedGenreNames.isEmpty()) {
|
||||||
|
"All genres"
|
||||||
|
} else {
|
||||||
|
"${selectedGenreNames.size} selected"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
visibleGenres.forEachIndexed { index, genre ->
|
||||||
|
GenreChip(
|
||||||
|
genre = genre,
|
||||||
|
selected = genre.name in selectedGenreNames,
|
||||||
|
onClick = { onGenreSelected(genre) },
|
||||||
|
modifier = Modifier.testTag("$SearchGenreChipTagPrefix$index")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showGenres) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showGenres = false },
|
||||||
|
modifier = Modifier.testTag(SearchGenreDialogTag),
|
||||||
|
title = {
|
||||||
|
Text(text = "Select genres")
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 360.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
genres.forEachIndexed { index, genre ->
|
||||||
|
GenreChip(
|
||||||
|
genre = genre,
|
||||||
|
selected = genre.name in selectedGenreNames,
|
||||||
|
onClick = { onGenreSelected(genre) },
|
||||||
|
modifier = Modifier.testTag("$SearchGenreDialogChipTagPrefix$index")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showGenres = false }) {
|
||||||
|
Text(text = "Done")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (selectedGenreNames.isNotEmpty()) {
|
||||||
|
TextButton(onClick = onClearGenres) {
|
||||||
|
Text(text = "Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreChip(
|
||||||
|
genre: Genre,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onClick,
|
||||||
|
leadingIcon = if (selected) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = genre.name,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun SearchScreenPreview() {
|
||||||
|
var query by rememberSaveable { mutableStateOf("du") }
|
||||||
|
|
||||||
|
AppTheme {
|
||||||
|
SearchScreenContent(
|
||||||
|
query = query,
|
||||||
|
searchResults = previewSearchResults,
|
||||||
|
genres = previewGenres,
|
||||||
|
selectedGenreNames = setOf("Action", "Comedy"),
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
onSearch = {},
|
||||||
|
onResultClick = {},
|
||||||
|
onGenreSelected = {},
|
||||||
|
onClearGenres = {},
|
||||||
|
onBack = {},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewGenres = listOf(
|
||||||
|
Genre(name = "Action"),
|
||||||
|
Genre(name = "Adventure"),
|
||||||
|
Genre(name = "Comedy"),
|
||||||
|
Genre(name = "Fantasy"),
|
||||||
|
Genre(name = "Romance"),
|
||||||
|
Genre(name = "Sci-Fi")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val previewSearchResults = listOf(
|
||||||
|
SearchResult(
|
||||||
|
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||||
|
title = "Dune",
|
||||||
|
posterUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||||
|
type = MediaKind.MOVIE
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||||
|
title = "Dune: Part Two",
|
||||||
|
posterUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580",
|
||||||
|
type = MediaKind.MOVIE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
internal const val SearchScreenTag = "search-screen"
|
||||||
|
internal const val SearchQueryFieldTag = "search-query-field"
|
||||||
|
internal const val SearchClearButtonTag = "search-clear-button"
|
||||||
|
internal const val SearchResultsGridTag = "search-results-grid"
|
||||||
|
internal const val SearchResultItemTagPrefix = "search-result-item-"
|
||||||
|
internal const val SearchGenreSelectorTag = "search-genre-selector"
|
||||||
|
internal const val SearchGenreChipTagPrefix = "search-genre-chip-"
|
||||||
|
internal const val SearchGenreDialogTag = "search-genre-dialog"
|
||||||
|
internal const val SearchGenreDialogChipTagPrefix = "search-genre-dialog-chip-"
|
||||||
@@ -2,40 +2,47 @@ package hu.bbara.purefin.ui.screen.libraries
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import hu.bbara.purefin.ui.common.DefaultTopBar
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||||
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
|
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
|
||||||
|
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesTopBar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LibrariesScreen(
|
fun LibrariesScreen(
|
||||||
items: List<HomeNavItem>,
|
items: List<LibraryUiModel>,
|
||||||
onLibrarySelected: (HomeNavItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
onProfileClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit,
|
|
||||||
onLogoutClick: () -> Unit,
|
|
||||||
selectedTab: Int,
|
selectedTab: Int,
|
||||||
|
isOnline: Boolean,
|
||||||
onTabSelected: (Int) -> Unit,
|
onTabSelected: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
topBar = {
|
topBar = {
|
||||||
DefaultTopBar(
|
LibrariesTopBar(
|
||||||
onProfileClick = onProfileClick,
|
onSearchClick = onSearchClick,
|
||||||
onSettingsClick = onSettingsClick,
|
scrollBehavior = topBarScrollBehavior
|
||||||
onLogoutClick = onLogoutClick
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(
|
AppBottomBar(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
|
isOnline = isOnline,
|
||||||
onTabSelected = onTabSelected
|
onTabSelected = onTabSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
package hu.bbara.purefin.ui.screen.libraries.components
|
package hu.bbara.purefin.ui.screen.libraries.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
|
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibrariesContent(
|
fun LibrariesContent(
|
||||||
items: List<HomeNavItem>,
|
items: List<LibraryUiModel>,
|
||||||
onLibrarySelected: (HomeNavItem) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
BoxWithConstraints(modifier = modifier.testTag(LibrariesOverviewGridTag)) {
|
||||||
modifier = modifier,
|
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
LazyVerticalGrid(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
columns = GridCells.Adaptive(minSize = minCellSize),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
items(items, key = { it.id }) { item ->
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
LibraryListItem(
|
) {
|
||||||
item = item,
|
items(items, key = { it.id }) { item ->
|
||||||
modifier = Modifier.clickable {
|
LibraryListItem(
|
||||||
onLibrarySelected(item)
|
uiModel = item,
|
||||||
}
|
onClick = { onLibrarySelected(item) },
|
||||||
)
|
modifier = Modifier.testTag("$LibrariesOverviewItemTagPrefix${item.id}")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal const val LibrariesOverviewGridTag = "libraries-overview-grid"
|
||||||
|
internal const val LibrariesOverviewItemTagPrefix = "libraries-overview-item-"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package hu.bbara.purefin.ui.screen.libraries.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||||
|
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarSearchButton
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LibrariesTopBar(
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
) {
|
||||||
|
DefaultTopBar(
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
rightActions = {
|
||||||
|
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user