mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
303 Commits
f086956578
...
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@
|
||||
local.properties
|
||||
.codex/
|
||||
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 TV: Select an Android TV emulator or device
|
||||
|
||||
### Download APK
|
||||
### APK Repository
|
||||
|
||||
_Release builds coming soon_
|
||||
APKs are distributed via the [APK Version Manager](https://apks.t.bbara.hu/docs) at `https://apks.t.bbara.hu`.
|
||||
|
||||
#### App names
|
||||
|
||||
| Variant | App name | Update URL |
|
||||
|---------|----------|--------------------------------------------------------------|
|
||||
| Release (phone) | `purefin-app` | `https://apks.t.bbara.hu/apps/purefin-app/update.json` |
|
||||
| Debug (phone) | `purefin-app-debug` | `https://apks.t.bbara.hu/apps/purefin-app-debug/update.json` |
|
||||
| Release (TV) | `purefin-tv` | `https://apks.t.bbara.hu/apps/purefin-tv/update.json` |
|
||||
|
||||
#### Building
|
||||
|
||||
```bash
|
||||
# Phone
|
||||
./gradlew :app:assembleDebug # debug APK
|
||||
./gradlew :app:assembleRelease # release APK (unsigned)
|
||||
|
||||
# Android TV
|
||||
./gradlew :app-tv:assembleDebug # debug APK
|
||||
./gradlew :app-tv:assembleRelease # release APK (unsigned)
|
||||
```
|
||||
|
||||
APK outputs go to `app/build/outputs/apk/<variant>/` and `app-tv/build/outputs/apk/<variant>/`.
|
||||
|
||||
#### Uploading
|
||||
|
||||
Upload via the API at `POST /api/apps/{app_name}/upload`:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://apks.t.bbara.hu/api/apps/purefin-app-debug/upload" \
|
||||
-F "apk_file=@app/build/outputs/apk/debug/app-debug.apk" \
|
||||
-F "version_code=$(grep purefinDebugVersionCode gradle.properties | cut -d= -f2)" \
|
||||
-F "version_name=0.1-debug"
|
||||
```
|
||||
|
||||
The `version_code` is defined in `gradle.properties` (`purefinReleaseVersionCode` for phone release, `purefinDebugVersionCode` for phone debug, `purefinTvVersionCode` for TV). The `version_name` is `0.1` for release or `0.1-debug` for debug. Both fields are required — the server cannot auto-extract them from the APK.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -7,17 +7,19 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.google.gms.google.services)
|
||||
alias(libs.plugins.google.firebase.crashlytics)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.tv"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "hu.bbara.purefin.tv"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 2000001
|
||||
versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
|
||||
versionName = "0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -51,11 +53,15 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":data"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":core-model"))
|
||||
implementation(project(":core-ui"))
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -82,6 +88,7 @@ dependencies {
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
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(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 2,
|
||||
index = 4,
|
||||
title = "The You You Are",
|
||||
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",
|
||||
|
||||
@@ -19,10 +19,10 @@ import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -385,13 +385,14 @@ class TvHomeContentTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleLibraries(): List<LibraryItem> {
|
||||
private fun sampleLibraries(): List<LibraryUiModel> {
|
||||
return listOf(
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = sampleLibraryId(),
|
||||
name = "Movies",
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
@@ -425,7 +426,9 @@ class TvHomeContentTest {
|
||||
return Episode(
|
||||
id = UUID.fromString(id),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Firefly",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = title,
|
||||
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
|
||||
|
||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TvNavigationDrawerTest {
|
||||
@@ -56,20 +59,26 @@ class TvNavigationDrawerTest {
|
||||
|
||||
@Test
|
||||
fun tvNavigationDrawer_updatesSelectedDestination() {
|
||||
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME)
|
||||
val libraryRoute = Route.LibraryRoute(
|
||||
library = LibraryDto(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Movies"
|
||||
)
|
||||
)
|
||||
var selectedDestination by mutableStateOf<Route>(Route.Home)
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvNavigationDrawer(
|
||||
destinations = listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
destination = Route.Home,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home
|
||||
),
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.LIBRARIES,
|
||||
label = "Libraries",
|
||||
destination = libraryRoute,
|
||||
label = "Movies",
|
||||
icon = Icons.Outlined.Collections
|
||||
)
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
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 org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
@@ -20,22 +20,24 @@ class TvLibrariesOverviewScreenTest {
|
||||
@Test
|
||||
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
|
||||
val libraries = listOf(
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
name = "Movies",
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
),
|
||||
LibraryItem(
|
||||
LibraryUiModel(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Shows",
|
||||
type = LibraryKind.SERIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
var openedLibrary: LibraryItem? = null
|
||||
var openedLibrary: LibraryUiModel? = null
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
|
||||
@@ -1,979 +0,0 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.player.model.TrackOption
|
||||
import hu.bbara.purefin.player.model.TrackType
|
||||
import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
|
||||
import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
private const val TvPlayerRootTag = "tv_player_root"
|
||||
private const val TvPlayerHiddenPauseCountTag = "tv_player_hidden_pause_count"
|
||||
private const val TvPlayerHiddenResumeCountTag = "tv_player_hidden_resume_count"
|
||||
private const val TvPlayerHiddenSeekDeltaTag = "tv_player_hidden_seek_delta"
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TvPlayerControlsOverlayTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun requestingControlsFocus_focusesPlayPauseInsteadOfSeekBar() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState(),
|
||||
requestFocus = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.Focused,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downFromSeekBar_movesFocusToControlButtonsBeforePlaylist() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerSeekBarTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downFromControls_expandsPlaylistAndMovesFocusToCurrentQueueItem() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upFromCurrentQueueItem_collapsesPlaylistAndRestoresFocusedControl() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionUp)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"collapsed"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rightOnLastPlaylistItem_keepsFocusInPlaylist() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistStateTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"expanded"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistLastItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistShowsQueueAsRowAndCurrentItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState(),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistRowTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Currently Playing").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 2").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistFirstItemTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Current").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Already Played").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistFallsBackToFirstItemWhenNoCurrentItemExists_andFallbackItemCanReceiveFocus() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
OverlayHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "first",
|
||||
title = "Episode 1",
|
||||
subtitle = "Fallback entry",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "second",
|
||||
title = "Episode 2",
|
||||
subtitle = "Later",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
)
|
||||
),
|
||||
initiallyExpanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlaylistCurrentItemTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Episode 1").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanel_focusesSelectedTrackAndHasNoCloseButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
composeRule.onAllNodesWithContentDescription("Close").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTrackPanelWithoutSelection_focusesFirstTrack() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(selectedAudioTrackId = null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackFirstItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapeOnVisibleControls_hidesOverlayImmediately() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerPlayPauseButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Escape)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_centerWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_enterWhilePlaying_pausesWithoutShowingControlsAndShowsStopFeedback() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.Enter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_stopFeedbackAutoHidesAfterTimeout() {
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.advanceTimeBy(TV_HIDDEN_STOP_FEEDBACK_MS - 1)
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenStopFeedbackTag).assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.advanceTimeBy(1)
|
||||
composeRule.mainClock.advanceTimeBy(300)
|
||||
composeRule.mainClock.advanceTimeByFrame()
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
|
||||
composeRule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_centerWhilePaused_resumesWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(isPlaying = false),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenPauseCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"0"
|
||||
)
|
||||
)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenResumeCountTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"1"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_leftSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"-10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenControls_rightSeeksWithoutShowingControls() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState(),
|
||||
initialControlsVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerRootTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionRight)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerPlayPauseButtonTag).assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag(TvPlayerHiddenStopFeedbackTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.assert(
|
||||
SemanticsMatcher.expectValue(
|
||||
SemanticsProperties.StateDescription,
|
||||
"10000"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backOnTrackPanel_closesItAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.Back)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftOnTrackPanel_keepsFocusInsidePanel() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionLeft)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackPanelTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingTrack_closesPanelAndRestoresFocusToOpeningButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(uiState = samplePlayerState())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag)
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerTrackSelectedItemTag)
|
||||
.assertIsFocused()
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onAllNodesWithTag(TvPlayerTrackPanelTag).assertCountEquals(0)
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackButtonsWithoutOptions_areDisabled() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TrackPanelHost(
|
||||
uiState = samplePlayerState().copy(
|
||||
audioTracks = emptyList(),
|
||||
textTracks = emptyList(),
|
||||
qualityTracks = emptyList(),
|
||||
selectedAudioTrackId = null,
|
||||
selectedTextTrackId = null,
|
||||
selectedQualityTrackId = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag(TvPlayerAudioButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerSubtitlesButtonTag).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(TvPlayerQualityButtonTag).assertIsNotEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayHost(
|
||||
uiState: PlayerUiState,
|
||||
initiallyExpanded: Boolean = false,
|
||||
requestFocus: Boolean = false
|
||||
) {
|
||||
var isPlaylistExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(requestFocus) {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.size(width = 960.dp, height = 540.dp)) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = focusRequester,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {},
|
||||
onOpenSubtitlesPanel = {},
|
||||
onOpenQualityPanel = {},
|
||||
onExpandPlaylist = { isPlaylistExpanded = true },
|
||||
onCollapsePlaylist = { isPlaylistExpanded = false },
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackPanelHost(
|
||||
uiState: PlayerUiState,
|
||||
initialControlsVisible: Boolean = true
|
||||
) {
|
||||
var controlsVisible by remember { mutableStateOf(initialControlsVisible) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
var pauseWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var resumeWithoutControlsCount by remember { mutableStateOf(0) }
|
||||
var lastHiddenSeekDeltaMs by remember { mutableStateOf(0L) }
|
||||
val rootFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
trackPanelType = null
|
||||
controlsVisible = true
|
||||
}
|
||||
}
|
||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||
pauseWithoutControlsCount += 1
|
||||
stopFeedbackVisible = true
|
||||
stopFeedbackRequestId += 1
|
||||
}
|
||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||
resumeWithoutControlsCount += 1
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
lastHiddenSeekDeltaMs = deltaMs
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
when (pendingFocus) {
|
||||
TvTrackPanelType.AUDIO -> audioButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.SUBTITLES -> subtitlesButtonFocusRequester.requestFocus()
|
||||
TvTrackPanelType.QUALITY -> qualityButtonFocusRequester.requestFocus()
|
||||
}
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
LaunchedEffect(stopFeedbackRequestId) {
|
||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible, trackPanelType) {
|
||||
if (controlsVisible || trackPanelType != null) {
|
||||
stopFeedbackVisible = false
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 960.dp, height = 540.dp)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.focusable()
|
||||
.testTag(TvPlayerRootTag)
|
||||
.onPreviewKeyEvent { event ->
|
||||
handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
isPlaying = uiState.isPlaying,
|
||||
controlsVisible = controlsVisible,
|
||||
isPlaylistExpanded = false,
|
||||
trackPanelType = trackPanelType,
|
||||
onCloseTrackPanel = closeTrackPanel,
|
||||
onCollapsePlaylist = { controlsVisible = true },
|
||||
onHideControls = { controlsVisible = false },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekWithoutShowingControls,
|
||||
onShowControls = { controlsVisible = true },
|
||||
onTogglePlayPause = { controlsVisible = true }
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (controlsVisible || trackPanelType != null) {
|
||||
TvPlayerControlsOverlay(
|
||||
uiState = uiState,
|
||||
focusRequester = controlsFocusRequester,
|
||||
isPlaylistExpanded = false,
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = {},
|
||||
onSeek = { _ -> },
|
||||
onSeekRelative = { _ -> },
|
||||
onSeekLiveEdge = {},
|
||||
onSkipSegment = {},
|
||||
onNext = {},
|
||||
onPrevious = {},
|
||||
onOpenAudioPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.AUDIO
|
||||
},
|
||||
onOpenSubtitlesPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.SUBTITLES
|
||||
},
|
||||
onOpenQualityPanel = {
|
||||
controlsVisible = true
|
||||
trackPanelType = TvTrackPanelType.QUALITY
|
||||
},
|
||||
onExpandPlaylist = {},
|
||||
onCollapsePlaylist = {},
|
||||
onSelectQueueItem = { _ -> },
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
subtitlesButtonEnabled = uiState.textTracks.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = stopFeedbackVisible,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerHiddenStopFeedback()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenPauseCountTag)
|
||||
.semantics { stateDescription = pauseWithoutControlsCount.toString() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenResumeCountTag)
|
||||
.semantics { stateDescription = resumeWithoutControlsCount.toString() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.testTag(TvPlayerHiddenSeekDeltaTag)
|
||||
.semantics { stateDescription = lastHiddenSeekDeltaMs.toString() }
|
||||
)
|
||||
|
||||
trackPanelType?.let { panelType ->
|
||||
TvTrackSelectionPanel(
|
||||
panelType = panelType,
|
||||
uiState = uiState,
|
||||
onSelect = { closeTrackPanel() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun samplePlayerState(): PlayerUiState = PlayerUiState(
|
||||
isPlaying = true,
|
||||
title = "Sample Player",
|
||||
subtitle = "Season 1",
|
||||
durationMs = 3_600_000,
|
||||
positionMs = 120_000,
|
||||
bufferedMs = 240_000,
|
||||
queue = listOf(
|
||||
PlaylistElementUiModel(
|
||||
id = "played",
|
||||
title = "Already Played",
|
||||
subtitle = "Episode 0",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "current",
|
||||
title = "Currently Playing",
|
||||
subtitle = "Episode 1",
|
||||
artworkUrl = null,
|
||||
isCurrent = true
|
||||
),
|
||||
PlaylistElementUiModel(
|
||||
id = "next",
|
||||
title = "Episode 2",
|
||||
subtitle = "Up next",
|
||||
artworkUrl = null,
|
||||
isCurrent = false
|
||||
)
|
||||
),
|
||||
audioTracks = listOf(
|
||||
TrackOption(
|
||||
id = "audio_en",
|
||||
label = "English 5.1",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = 6,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 0,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "audio_hu",
|
||||
label = "Hungarian Stereo",
|
||||
language = "hu",
|
||||
bitrate = null,
|
||||
channelCount = 2,
|
||||
height = null,
|
||||
groupIndex = 0,
|
||||
trackIndex = 1,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
textTracks = listOf(
|
||||
TrackOption(
|
||||
id = "text_off",
|
||||
label = "Off",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = -1,
|
||||
type = TrackType.TEXT,
|
||||
isOff = true
|
||||
),
|
||||
TrackOption(
|
||||
id = "text_en",
|
||||
label = "English CC",
|
||||
language = "en",
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = 1,
|
||||
trackIndex = 0,
|
||||
type = TrackType.TEXT,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
qualityTracks = listOf(
|
||||
TrackOption(
|
||||
id = "quality_auto",
|
||||
label = "Auto",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 1080,
|
||||
groupIndex = 2,
|
||||
trackIndex = 0,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
),
|
||||
TrackOption(
|
||||
id = "quality_720",
|
||||
label = "720p",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = 720,
|
||||
groupIndex = 2,
|
||||
trackIndex = 1,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
)
|
||||
),
|
||||
selectedAudioTrackId = "audio_hu",
|
||||
selectedTextTrackId = "text_en",
|
||||
selectedQualityTrackId = "quality_auto"
|
||||
)
|
||||
@@ -17,7 +17,7 @@ import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesNextUpEpisodeCardTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -30,7 +30,7 @@ class SeriesScreenContentTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvSeriesScreenContent(
|
||||
@@ -44,8 +44,7 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||
@@ -66,7 +65,6 @@ class SeriesScreenContentTest {
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||
.assertIsDisplayed()
|
||||
.assertIsFocused()
|
||||
@@ -87,7 +85,7 @@ class SeriesScreenContentTest {
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
||||
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
|
||||
composeRule.onRoot().performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
@@ -122,7 +120,9 @@ class SeriesScreenContentTest {
|
||||
Episode(
|
||||
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = "Good News About Hell",
|
||||
synopsis = "Mark is promoted after an unexpected tragedy.",
|
||||
@@ -138,7 +138,9 @@ class SeriesScreenContentTest {
|
||||
Episode(
|
||||
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 1,
|
||||
index = 2,
|
||||
title = "Half Loop",
|
||||
synopsis = "Mark takes the team out for a sanctioned dinner.",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -23,7 +25,11 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/purefin_logo_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="https://apks.t.bbara.hu/apps/purefin-app-tv/update.json" />
|
||||
<activity
|
||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||
android:exported="true"
|
||||
@@ -34,6 +40,9 @@
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -6,13 +6,14 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityRetainedComponent
|
||||
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.tvHomeSection
|
||||
import hu.bbara.purefin.navigation.tvLibrarySection
|
||||
import hu.bbara.purefin.navigation.tvLoginSection
|
||||
import hu.bbara.purefin.navigation.tvMovieSection
|
||||
import hu.bbara.purefin.navigation.tvPlayerSection
|
||||
import hu.bbara.purefin.navigation.tvSettingsSection
|
||||
import hu.bbara.purefin.navigation.tvSeriesSection
|
||||
|
||||
@Module
|
||||
@@ -60,4 +61,10 @@ object TvNavigationModule {
|
||||
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvLibrarySection()
|
||||
}
|
||||
|
||||
@IntoSet
|
||||
@Provides
|
||||
fun provideTvSettingsEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
|
||||
tvSettingsSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.data.PlaybackProfileFamily
|
||||
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
||||
@@ -3,6 +3,8 @@ package hu.bbara.purefin.navigation
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
|
||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.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.library.TvLibraryScreen
|
||||
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
|
||||
import hu.bbara.purefin.ui.screen.settings.TvSettingsScreen
|
||||
|
||||
fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
entry<Route.Home> {
|
||||
@@ -17,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
|
||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
TvLoginScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +39,11 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
|
||||
|
||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
||||
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() {
|
||||
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.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -11,15 +13,20 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
@@ -36,15 +43,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
import hu.bbara.purefin.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
||||
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.dialog.ExitAppDialog
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.login.LoginScreen
|
||||
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.ui.theme.backgroundDark
|
||||
@@ -135,10 +145,14 @@ class TvActivity : ComponentActivity() {
|
||||
fun MainApp(
|
||||
userSessionRepository: UserSessionRepository,
|
||||
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
||||
navigationManager: NavigationManager
|
||||
navigationManager: NavigationManager,
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
@@ -146,12 +160,22 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.checkForUpdatesOnAppOpen()
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
LaunchedEffect(updateViewModel, context) {
|
||||
updateViewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
val appEntryProvider = entryProvider {
|
||||
@@ -161,7 +185,13 @@ class TvActivity : ComponentActivity() {
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
NavigationCommand.Pop -> {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
@@ -171,13 +201,23 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
LocalNavigationBackStack provides backStack.toList()
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
navigationManager.pop()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
@@ -211,7 +251,56 @@ class TvActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LoginScreen()
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
TvLoginScreen()
|
||||
}
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
TvUpdateAvailableDialog(
|
||||
update = update,
|
||||
onAccept = updateViewModel::acceptUpdate,
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitAppDialog(
|
||||
onCloseConfirmed = { finishAndRemoveTask() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvUpdateAvailableDialog(
|
||||
update: AppUpdateInfo,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit
|
||||
) {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
||||
val updateText = if (releaseNotes == null) {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
||||
} else {
|
||||
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = { Text("Update available") },
|
||||
text = { Text(updateText) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAccept) {
|
||||
Text("Install")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDecline) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package hu.bbara.purefin.tv
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||
|
||||
@HiltAndroidApp
|
||||
class TvApplication : Application()
|
||||
class TvApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PurefinLogger.initialize(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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.sp
|
||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.ui.model.SeriesUiModel
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import java.util.UUID
|
||||
@@ -43,7 +44,6 @@ fun PosterCard(
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier = Modifier,
|
||||
posterWidth: Dp = 144.dp,
|
||||
contentScale: Float = 1f,
|
||||
showSecondaryText: Boolean = false,
|
||||
indicatorSize: Int = 28,
|
||||
indicatorPadding: Dp = 8.dp,
|
||||
@@ -65,7 +65,6 @@ fun PosterCard(
|
||||
modifier = modifier,
|
||||
imageModifier = imageModifier,
|
||||
posterWidth = posterWidth,
|
||||
contentScale = contentScale,
|
||||
showSecondaryText = showSecondaryText,
|
||||
indicatorSize = indicatorSize,
|
||||
indicatorPadding = indicatorPadding,
|
||||
@@ -82,11 +81,10 @@ fun PosterCardContent(
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier = Modifier,
|
||||
posterWidth: Dp = 144.dp,
|
||||
contentScale: Float = 1f,
|
||||
showSecondaryText: Boolean = false,
|
||||
indicatorSize: Int = 28,
|
||||
indicatorPadding: Dp = 8.dp,
|
||||
onFocused: () -> Unit = {},
|
||||
onFocused: () -> Unit,
|
||||
focusedScale: Float = 1f,
|
||||
focusedBorderWidth: Dp = 1.dp,
|
||||
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
|
||||
@@ -137,10 +135,17 @@ fun PosterCardContent(
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(indicatorPadding),
|
||||
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(
|
||||
@@ -155,6 +160,7 @@ fun PosterCardContent(
|
||||
text = model.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -167,6 +173,7 @@ fun PosterCardContent(
|
||||
text = text,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
@@ -15,14 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -31,13 +27,13 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
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
|
||||
private val MediaDetailHeaderTopPadding = 104.dp
|
||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
||||
private val MediaDetailHeaderTopPadding = 6.dp
|
||||
private val MediaDetailHeaderBottomPadding = 4.dp
|
||||
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||
|
||||
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||
@@ -61,19 +57,13 @@ internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||
internal fun TvMediaDetailScaffold(
|
||||
resetScrollKey: Any,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
||||
bodyContent: @Composable (Modifier) -> Unit = {}
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(resetScrollKey) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
@@ -87,7 +77,7 @@ internal fun TvMediaDetailScaffold(
|
||||
internal fun TvMediaDetailBodyBox(
|
||||
backgroundImageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = 0.48f,
|
||||
heightFraction: Float = 0.24f,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -99,44 +89,46 @@ internal fun TvMediaDetailBodyBox(
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = bodyHeight)
|
||||
) {
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = backgroundImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(MediaDetailBodyImageWidthFraction)
|
||||
.align(Alignment.TopEnd),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||
1.0f to scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -183,12 +175,11 @@ internal fun MediaDetailOverviewSection(
|
||||
title = "Overview",
|
||||
titleColor = scheme.onBackground,
|
||||
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||
titleFontSize = 22.sp,
|
||||
bodyFontSize = 16.sp,
|
||||
bodyLineHeight = 24.sp,
|
||||
titleSpacing = 14.dp,
|
||||
collapsedLines = 5,
|
||||
collapseInitially = false
|
||||
titleFontSize = 15.sp,
|
||||
bodyFontSize = 11.sp,
|
||||
bodyLineHeight = 14.sp,
|
||||
titleSpacing = 2.dp,
|
||||
collapsedLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -81,8 +81,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onBack,
|
||||
modifier = backModifier.then(downModifier),
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
if (shortcut != null) {
|
||||
@@ -100,8 +98,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onCastClick,
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
GhostIconButton(
|
||||
@@ -110,8 +106,6 @@ internal fun MediaDetailsTopBar(
|
||||
onClick = onMoreClick,
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,74 @@
|
||||
package hu.bbara.purefin.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import 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.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.components.TvDrawerDestination
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
||||
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
|
||||
fun TvAppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val libraries by viewModel.libraries.collectAsState()
|
||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||
val nextUp by viewModel.nextUp.collectAsState()
|
||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
||||
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
|
||||
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
|
||||
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
|
||||
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(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
destination = Route.Home,
|
||||
label = "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
|
||||
icon = Icons.Outlined.Home
|
||||
)
|
||||
) + libraries.map { library ->
|
||||
val destination = Route.LibraryRoute(
|
||||
library = LibraryDto(id = library.id, name = library.name)
|
||||
)
|
||||
TvDrawerDestinationItem(
|
||||
destination = destination,
|
||||
label = library.name,
|
||||
icon = when (library.type) {
|
||||
LibraryKind.MOVIES -> Icons.Outlined.Movie
|
||||
LibraryKind.SERIES -> Icons.Outlined.Tv
|
||||
}
|
||||
)
|
||||
} + TvDrawerDestinationItem(
|
||||
destination = Route.SettingsRoute,
|
||||
label = "Settings",
|
||||
icon = Icons.Outlined.Settings
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,34 +77,68 @@ fun TvAppScreen(
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val tvEntryProvider = entryProvider {
|
||||
entry<Route.Home> {
|
||||
TvHomeScreen(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaSelected = viewModel::onMediaSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<Route.LibraryRoute> { route ->
|
||||
TvLibraryScreen(
|
||||
library = route.library,
|
||||
onMediaSelected = viewModel::onMediaSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<Route.SettingsRoute> {
|
||||
TvSettingsScreen(
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
TvNavigationDrawer(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
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()
|
||||
) {
|
||||
when (selectedDestination) {
|
||||
TvDrawerDestination.HOME -> {
|
||||
TvHomeScreen(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaSelected = viewModel::onMediaSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
TvDrawerDestination.LIBRARIES -> {
|
||||
TvLibrariesOverviewScreen(
|
||||
libraries = libraries,
|
||||
onLibrarySelected = { library ->
|
||||
viewModel.onLibrarySelected(library.id, library.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
navigationManager.pop()
|
||||
}
|
||||
},
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = tvEntryProvider,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||
@@ -37,15 +37,10 @@ fun TvEpisodeScreen(
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
|
||||
LaunchedEffect(episode) {
|
||||
viewModel.selectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
)
|
||||
viewModel.selectEpisode(episode)
|
||||
}
|
||||
|
||||
val episode = viewModel.episode.collectAsState()
|
||||
val seriesTitle = viewModel.seriesTitle.collectAsState()
|
||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||
val selectedEpisode = episode.value
|
||||
|
||||
if (selectedEpisode == null) {
|
||||
@@ -55,7 +50,7 @@ fun TvEpisodeScreen(
|
||||
|
||||
TvEpisodeScreenContent(
|
||||
episode = selectedEpisode,
|
||||
seriesTitle = seriesTitle.value,
|
||||
seriesTitle = selectedEpisode.seriesName,
|
||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||
{
|
||||
navigationManager.navigate(
|
||||
@@ -85,50 +80,43 @@ internal fun TvEpisodeScreenContent(
|
||||
resetScrollKey = episode.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item(key = "episode-hero") {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = it
|
||||
) {
|
||||
TvEpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
|
||||
episode.imageUrlPrefix,
|
||||
ArtworkKind.PRIMARY
|
||||
),
|
||||
modifier = it
|
||||
) {
|
||||
TvEpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
item(key = "episode-overview") {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = episode.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = episode.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
item(key = "episode-playback") {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailPlaybackSection(
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
item(key = "episode-cast") {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
// MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
// MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,47 +39,47 @@ internal fun TvEpisodeHeroSection(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
) {
|
||||
if (!seriesTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = seriesTitle,
|
||||
color = scheme.primary,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||
highlightedItem = episode.format
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(EpisodePlayButtonTag),
|
||||
focusedScale = 1.08f,
|
||||
@@ -88,7 +88,10 @@ internal fun TvEpisodeHeroSection(
|
||||
focusBorderColor = scheme.onBackground,
|
||||
overlayBorderWidth = 2.dp,
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -10,17 +10,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
||||
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.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun TvHomeScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
nextUp: List<MediaUiModel>,
|
||||
@@ -31,31 +31,33 @@ fun TvHomeScreen(
|
||||
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = scheme.background
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
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()
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
TvHomeHeroBackdrop(
|
||||
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
TvFocusedItemHero(
|
||||
item = focusedMediaUiModel.value
|
||||
)
|
||||
TvHomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaFocused = {
|
||||
focusedMediaUiModel.value = it
|
||||
},
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,118 +2,136 @@ package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 kotlin.math.roundToInt
|
||||
|
||||
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
// The billboard backdrop covers the top portion of the home screen so the hero
|
||||
// text sits at the bottom of the backdrop and the content rows begin below it.
|
||||
internal const val TvHomeHeroHeightFraction = 0.45f
|
||||
internal const val TvHomeBackdropOffset = 0.20f
|
||||
|
||||
/**
|
||||
* The home screen background image plus the darkening gradients that keep the
|
||||
* hero text readable. Drawn behind the home content and sized to the top
|
||||
* [TvHomeHeroHeightFraction] of the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
height: Dp,
|
||||
internal fun TvHomeHeroBackdrop(
|
||||
backdropImageUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Crossfade(
|
||||
targetState = backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-backdrop",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = item.backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-background"
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
.fillMaxHeight(heightFraction)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
// Darken the left side so the text block stays readable over any backdrop.
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background,
|
||||
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||
1.0f to scheme.background.copy(alpha = 0.06f)
|
||||
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||
0.34f to scheme.background.copy(alpha = 0.62f),
|
||||
0.66f to scheme.background.copy(alpha = 0.22f),
|
||||
1.0f to scheme.background.copy(alpha = 0.04f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
// Strong bottom fade so the title and metadata are legible.
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Crossfade(
|
||||
targetState = item,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-content"
|
||||
) { hero ->
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Crossfade(
|
||||
targetState = item,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-content",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(heightFraction)
|
||||
) { hero ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 40.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 40.dp, vertical = 18.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = 720.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.widthIn(max = 720.dp)
|
||||
) {
|
||||
Text(
|
||||
text = hero.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||
)
|
||||
Text(
|
||||
text = hero.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 44.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag(TvHomeHeroTitleTag)
|
||||
)
|
||||
TvHomeHeroMetadataRow(item = hero)
|
||||
if (hero.description.isNotBlank()) {
|
||||
Text(
|
||||
text = hero.description,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
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 kotlin.math.abs
|
||||
|
||||
internal val TvHomeFocusedItemTopOffset = 42.dp
|
||||
internal val TvHomeFocusedItemTopOffset = 48.dp
|
||||
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
|
||||
|
||||
private const val TvHomeRowPivotParentFraction = 0.3f
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -20,11 +19,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import java.util.UUID
|
||||
|
||||
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||
@@ -32,7 +31,7 @@ internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
|
||||
|
||||
@Composable
|
||||
fun TvHomeContent(
|
||||
libraries: List<LibraryItem>,
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
nextUp: List<MediaUiModel>,
|
||||
@@ -41,14 +40,10 @@ fun TvHomeContent(
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||
val hasNextUp = nextUp.isNotEmpty()
|
||||
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
|
||||
val firstLibraryWithItemsId = libraries.firstOrNull {
|
||||
libraryContent[it.id].orEmpty().isNotEmpty()
|
||||
}?.id
|
||||
val hasVisibleContent = hasContinueWatching || hasNextUp
|
||||
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
|
||||
val initialFocusRequester = remember { FocusRequester() }
|
||||
@@ -67,22 +62,16 @@ fun TvHomeContent(
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.testTag(TvHomeContentViewportTag),
|
||||
.focusRequester(initialFocusRequester),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
item(key = "tv-home-top-spacer") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
if (hasContinueWatching) {
|
||||
item(key = "tv-home-continue-watching") {
|
||||
TvContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMediaSelected = onMediaSelected,
|
||||
firstItemFocusRequester = initialFocusRequester,
|
||||
firstItemTestTag = TvHomeInitialFocusTag,
|
||||
rowTestTag = TvHomeContinueWatchingRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,9 +82,6 @@ fun TvHomeContent(
|
||||
items = nextUp,
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMediaSelected = onMediaSelected,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
|
||||
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
|
||||
rowTestTag = TvHomeNextUpRowTag
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,10 +94,6 @@ fun TvHomeContent(
|
||||
title = library.name,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onFocusedItem = onMediaFocused,
|
||||
firstItemFocusRequester = initialFocusRequester.takeIf {
|
||||
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
|
||||
},
|
||||
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
|
||||
onMediaSelected = onMediaSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
enum class TvDrawerDestination {
|
||||
HOME,
|
||||
LIBRARIES
|
||||
}
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
|
||||
data class TvDrawerDestinationItem(
|
||||
val destination: TvDrawerDestination,
|
||||
val destination: Route,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val selected: Boolean = false
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
@@ -5,21 +5,24 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -28,26 +31,27 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.card.PosterCardContent
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -61,15 +65,14 @@ private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataSpacing = (4f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataTopPadding = (12f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataSpacing = (1f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataTopPadding = (6f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
|
||||
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
|
||||
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
|
||||
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
|
||||
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
|
||||
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
|
||||
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
|
||||
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
|
||||
|
||||
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
|
||||
@@ -81,91 +84,59 @@ internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
|
||||
@Composable
|
||||
fun TvContinueWatchingSection(
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Continue Watching",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
progress = item.progress ?: 0f,
|
||||
imageModifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
onFocusedItem = { onFocusedItem(item) },
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
progress = item.progress ?: 0f,
|
||||
imageModifier = focusModifier,
|
||||
onFocusedItem = {
|
||||
onFocused(item)
|
||||
},
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvNextUpSection(
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = "Next Up",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
imageModifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
onFocusedItem = { onFocusedItem(item) },
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
TvHomeLandscapeCard(
|
||||
title = item.primaryText,
|
||||
supporting = item.secondaryText,
|
||||
imageUrl = item.primaryImageUrl,
|
||||
imageModifier = focusModifier,
|
||||
onFocusedItem = { onFocused(item) },
|
||||
onClick = { onMediaSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,74 +144,75 @@ fun TvNextUpSection(
|
||||
fun TvLibraryPosterSection(
|
||||
title: String,
|
||||
items: List<MediaUiModel>,
|
||||
onFocusedItem: (MediaUiModel) -> Unit = {},
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
rowTestTag: String? = null,
|
||||
onFocusedItem: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
TvSectionHeader(
|
||||
title = title,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TvHomeSectionRow(
|
||||
items = items,
|
||||
onFocused = onFocusedItem,
|
||||
modifier = modifier,
|
||||
rowTestTag = rowTestTag
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
PosterCardContent(
|
||||
model = item,
|
||||
onClick = { onMediaSelected(item) },
|
||||
posterWidth = TvHomePosterCardWidth,
|
||||
contentScale = TvHomeMediaCardScale,
|
||||
showSecondaryText = true,
|
||||
indicatorSize = TvHomePosterIndicatorSize,
|
||||
indicatorPadding = TvHomePosterIndicatorPadding,
|
||||
imageModifier = Modifier
|
||||
.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
|
||||
)
|
||||
}
|
||||
) { item, onFocused, focusModifier ->
|
||||
PosterCardContent(
|
||||
model = item,
|
||||
onClick = { onMediaSelected(item) },
|
||||
onFocused = { onFocused(item) },
|
||||
posterWidth = TvHomePosterCardWidth,
|
||||
showSecondaryText = true,
|
||||
indicatorSize = TvHomePosterIndicatorSize,
|
||||
indicatorPadding = TvHomePosterIndicatorPadding,
|
||||
imageModifier = focusModifier,
|
||||
focusedScale = 1.07f,
|
||||
focusedBorderWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TvHomeSectionRow(
|
||||
items: List<MediaUiModel>,
|
||||
onFocused: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowTestTag: String? = null,
|
||||
content: LazyListScope.() -> Unit,
|
||||
childComponent: @Composable (
|
||||
item: MediaUiModel,
|
||||
onFocused: (MediaUiModel) -> Unit,
|
||||
focusModifier: Modifier,
|
||||
) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var focusedItemId by rememberSaveable { mutableStateOf(items.first().id) }
|
||||
val rowScopedFocusRequester = remember { FocusRequester() }
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (rowTestTag != null) {
|
||||
Modifier.testTag(rowTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
.focusRestorer(rowScopedFocusRequester)
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
|
||||
content = content
|
||||
)
|
||||
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
val focusModifier =
|
||||
if (item.id == focusedItemId) Modifier.focusRequester(rowScopedFocusRequester) else Modifier
|
||||
childComponent(
|
||||
item,
|
||||
{
|
||||
focusedItemId = item.id
|
||||
onFocused(item)
|
||||
},
|
||||
focusModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +251,10 @@ private fun TvHomeLandscapeCard(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale")
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.055f else 1f,
|
||||
label = "tv-home-landscape-scale"
|
||||
)
|
||||
val cardWidth = TvHomeLandscapeCardWidth
|
||||
|
||||
Column(
|
||||
@@ -359,6 +334,7 @@ private fun TvHomeLandscapeCard(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = TvHomeLandscapeTitleFontSize,
|
||||
lineHeight = TvHomeLandscapeTitleFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -368,6 +344,7 @@ private fun TvHomeLandscapeCard(
|
||||
text = supporting,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = TvHomeLandscapeSupportingFontSize,
|
||||
lineHeight = TvHomeLandscapeSupportingFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,47 +12,56 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.DrawerState
|
||||
import androidx.tv.material3.DrawerValue
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.NavigationDrawer
|
||||
import androidx.tv.material3.NavigationDrawerItem
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import androidx.tv.material3.rememberDrawerState
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.tv.R
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
|
||||
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
|
||||
internal const val TvDrawerTitleTag = "tv-drawer-title"
|
||||
|
||||
private val TvDrawerCollapsedWidth = 92.dp
|
||||
private val TvDrawerExpandedWidth = 280.dp
|
||||
|
||||
@Composable
|
||||
fun TvNavigationDrawer(
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
selectedDestination: Route,
|
||||
onDestinationSelected: (Route) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
ProvideTvDrawerTheme {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
||||
NavigationDrawer(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
TvNavigationDrawerRail(
|
||||
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
|
||||
drawerState = drawerState,
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected
|
||||
@@ -122,6 +130,8 @@ fun TvDrawerHeader(
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = "Purefin",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.testTag(TvDrawerTitleTag)
|
||||
)
|
||||
}
|
||||
@@ -130,60 +140,59 @@ fun TvDrawerHeader(
|
||||
|
||||
@Composable
|
||||
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||
drawerValue: DrawerValue,
|
||||
drawerState: DrawerState,
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
selectedDestination: Route,
|
||||
onDestinationSelected: (Route) -> Unit,
|
||||
) {
|
||||
val expanded = drawerValue == DrawerValue.Open
|
||||
val drawerWidth = animateDpAsState(
|
||||
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
|
||||
label = "tv-drawer-width"
|
||||
)
|
||||
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
|
||||
val expanded = drawerState.currentValue == DrawerValue.Open
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var hasDrawerFocus by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(drawerWidth.value)
|
||||
.fillMaxHeight()
|
||||
.background(scheme.surface.copy(alpha = 0.96f))
|
||||
.padding(horizontal = 12.dp, vertical = 24.dp)
|
||||
.selectableGroup()
|
||||
.focusGroup()
|
||||
.onFocusChanged { state ->
|
||||
hasDrawerFocus = state.isFocused
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
TvDrawerHeader(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = { onDestinationSelected(destination.destination) },
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (expanded) {
|
||||
Text(
|
||||
text = destination.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
TvDrawerHeader(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
onDestinationSelected(destination.destination)
|
||||
if (isSelected) {
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected }
|
||||
.focusProperties { canFocus = hasDrawerFocus || isSelected },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = destination.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
|
||||
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
||||
@@ -52,8 +52,8 @@ private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
|
||||
|
||||
@Composable
|
||||
fun TvLibrariesOverviewScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
libraries: List<LibraryUiModel>,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -117,7 +117,7 @@ fun TvLibrariesOverviewScreen(
|
||||
|
||||
@Composable
|
||||
private fun TvLibraryOverviewCard(
|
||||
item: LibraryItem,
|
||||
item: LibraryUiModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
||||
@@ -4,34 +4,30 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.navigation.LibraryDto
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
|
||||
import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
||||
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.ui.common.card.PosterCard
|
||||
import java.util.UUID
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
@Composable
|
||||
fun TvLibraryScreen(
|
||||
library: LibraryDto,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -39,21 +35,19 @@ fun TvLibraryScreen(
|
||||
viewModel.selectLibrary(libraryId = library.id)
|
||||
}
|
||||
|
||||
val libraryItems = viewModel.contents.collectAsState()
|
||||
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TvLibraryTopBar(
|
||||
title = library.name,
|
||||
onBack = viewModel::onBack
|
||||
title = library.name
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
TvLibraryContent(
|
||||
libraryItems = libraryItems.value,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
@@ -61,40 +55,23 @@ fun TvLibraryScreen(
|
||||
|
||||
@Composable
|
||||
private fun TvLibraryTopBar(
|
||||
title: String,
|
||||
onBack: () -> Unit
|
||||
title: String
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
PurefinIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
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)
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvLibraryContent(
|
||||
libraryItems: List<MediaUiModel>,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -108,9 +85,9 @@ fun TvLibraryContent(
|
||||
items(libraryItems, key = { item -> item.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> }
|
||||
onMovieSelected = { onMediaSelected(item) },
|
||||
onSeriesSelected = { onMediaSelected(item) },
|
||||
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,24 +1,21 @@
|
||||
package hu.bbara.purefin.ui.screen.movie
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||
@@ -27,13 +24,15 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun TvMovieScreen(
|
||||
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
|
||||
movie: MovieDto,
|
||||
viewModel: MovieScreenViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(movie.id) {
|
||||
viewModel.selectMovie(movie.id)
|
||||
LaunchedEffect(movie) {
|
||||
viewModel.selectMovie(movie)
|
||||
}
|
||||
|
||||
val movieItem = viewModel.movie.collectAsState()
|
||||
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
|
||||
|
||||
if (movieItem.value != null) {
|
||||
TvMovieScreenContent(
|
||||
@@ -63,10 +62,15 @@ internal fun TvMovieScreenContent(
|
||||
resetScrollKey = movie.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item(key = "movie-hero") {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||
modifier = it
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
heightFraction = 1f
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
) {
|
||||
TvMovieHeroSection(
|
||||
movie = movie,
|
||||
@@ -74,37 +78,6 @@ internal fun TvMovieScreenContent(
|
||||
playFocusRequester = playFocusRequester,
|
||||
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 = "ENG",
|
||||
subtitles = "ENG",
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -17,11 +17,11 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
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"
|
||||
|
||||
@@ -33,33 +33,67 @@ internal fun TvMovieHeroSection(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, 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(
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(MoviePlayButtonTag),
|
||||
focusedScale = 1.08f,
|
||||
@@ -68,7 +102,10 @@ internal fun TvMovieHeroSection(
|
||||
focusBorderColor = scheme.onBackground,
|
||||
overlayBorderWidth = 2.dp,
|
||||
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.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -12,23 +11,29 @@ import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,30 +42,45 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker
|
||||
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.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.31f
|
||||
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
|
||||
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
|
||||
|
||||
@@ -75,18 +95,42 @@ fun TvPlayerScreen(
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var isPlaylistExpanded by remember { mutableStateOf(false) }
|
||||
var trackPanelType by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var pendingTrackButtonFocus by remember { mutableStateOf<TvTrackPanelType?>(null) }
|
||||
var stopFeedbackVisible by remember { mutableStateOf(false) }
|
||||
var stopFeedbackRequestId by remember { mutableStateOf(0) }
|
||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||
|
||||
// This is a hack for timed visibility.
|
||||
var resumeStopFeedbackCounter by remember { mutableIntStateOf(0) }
|
||||
var hiddenSeekCounter by remember { mutableIntStateOf(0) }
|
||||
|
||||
|
||||
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) {
|
||||
val activity = context as? Activity
|
||||
@@ -96,104 +140,85 @@ fun TvPlayerScreen(
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
(context as? Activity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, false)
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.TRACK_PANEL, false)
|
||||
|
||||
fun showControls() {
|
||||
controlsVisible = true
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !isPlaylistExpanded && trackPanelType == null && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaylistExpanded) {
|
||||
viewModel.setControlsAutoHideBlocked(ControlsAutoHideBlocker.PLAYLIST, isPlaylistExpanded)
|
||||
}
|
||||
|
||||
LaunchedEffect(trackPanelType) {
|
||||
viewModel.setControlsAutoHideBlocked(
|
||||
ControlsAutoHideBlocker.TRACK_PANEL,
|
||||
trackPanelType != null
|
||||
)
|
||||
}
|
||||
|
||||
val hiddenControlFocusRequester = remember { FocusRequester() }
|
||||
val controlsFocusRequester = remember { FocusRequester() }
|
||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||
val expandPlaylist: () -> Unit = {
|
||||
if (!isPlaylistExpanded) {
|
||||
isPlaylistExpanded = true
|
||||
// Needed because of composition scheduling
|
||||
LaunchedEffect(controlsVisible) {
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
val collapsePlaylistToControls: () -> Unit = {
|
||||
if (isPlaylistExpanded) {
|
||||
isPlaylistExpanded = false
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_START) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
val showTvControls: () -> Unit = {
|
||||
viewModel.showControls(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
|
||||
fun expandPlaylist() {
|
||||
// TODO: focus management for playlist expansion
|
||||
isPlaylistExpanded = true
|
||||
}
|
||||
val togglePlayPauseAndShowControls: () -> Unit = {
|
||||
viewModel.togglePlayPause(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val pausePlaybackWithoutShowingControls: () -> Unit = {
|
||||
viewModel.pausePlayback()
|
||||
stopFeedbackVisible = true
|
||||
stopFeedbackRequestId += 1
|
||||
}
|
||||
val resumePlaybackWithoutShowingControls: () -> Unit = {
|
||||
viewModel.resumePlayback()
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekAndShowControls: (Long) -> Unit = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByAndShowControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
showTvControls()
|
||||
}
|
||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
val seekToLiveEdgeAndShowControls: () -> Unit = {
|
||||
viewModel.seekToLiveEdge()
|
||||
showTvControls()
|
||||
}
|
||||
val skipSegmentAndShowControls: () -> Unit = {
|
||||
viewModel.skipActiveSegment()
|
||||
showTvControls()
|
||||
}
|
||||
val nextAndShowControls: () -> Unit = {
|
||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
}
|
||||
val previousAndShowControls: () -> Unit = {
|
||||
viewModel.previous(TV_CONTROLS_AUTO_HIDE_MS)
|
||||
fun closePlaylist() {
|
||||
// TODO focus
|
||||
isPlaylistExpanded = false
|
||||
}
|
||||
val closeTrackPanel: () -> Unit = {
|
||||
trackPanelType?.let { panelType ->
|
||||
pendingTrackButtonFocus = panelType
|
||||
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
|
||||
if (controlsVisible) {
|
||||
controlsFocusRequester.requestFocus()
|
||||
} else {
|
||||
uiState.activeSkippableSegmentEndMs?.let {
|
||||
skipButtonFocusRequester.requestFocus()
|
||||
return@LaunchedEffect
|
||||
|
||||
val showSkipIntroButton = !controlsVisible
|
||||
&& uiState.activeSkippableSegmentEndMs != null
|
||||
&& uiState.activeSkippableSegmentType == SegmentType.INTRO
|
||||
&& !uiState.isEnded
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& ((uiState.durationMs - uiState.positionMs) <= 30_000L || uiState.activeSkippableSegmentType == SegmentType.OUTRO)
|
||||
&& !uiState.isEnded
|
||||
LaunchedEffect(showSkipIntroButton, showNextEpisodeOverlay) {
|
||||
rootFocusRequester = when {
|
||||
showSkipIntroButton -> {
|
||||
skipButtonFocusRequester
|
||||
}
|
||||
hiddenControlFocusRequester.requestFocus()
|
||||
showNextEpisodeOverlay -> {
|
||||
nextEpisodeFocusRequester
|
||||
}
|
||||
else -> {
|
||||
backgroundFocusRequester
|
||||
}
|
||||
}
|
||||
if (!controlsVisible) {
|
||||
rootFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(trackPanelType, pendingTrackButtonFocus) {
|
||||
val pendingFocus = pendingTrackButtonFocus ?: return@LaunchedEffect
|
||||
if (trackPanelType != null) return@LaunchedEffect
|
||||
@@ -205,57 +230,44 @@ fun TvPlayerScreen(
|
||||
pendingTrackButtonFocus = null
|
||||
}
|
||||
|
||||
LaunchedEffect(stopFeedbackRequestId) {
|
||||
if (stopFeedbackRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
|
||||
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible =
|
||||
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
when {
|
||||
trackPanelType != null -> closeTrackPanel()
|
||||
isPlaylistExpanded -> collapsePlaylistToControls()
|
||||
controlsVisible -> viewModel.toggleControlsVisibility()
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(hiddenControlFocusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
isPlaying = uiState.isPlaying,
|
||||
controlsVisible = controlsVisible,
|
||||
isPlaylistExpanded = isPlaylistExpanded,
|
||||
trackPanelType = trackPanelType,
|
||||
onCloseTrackPanel = closeTrackPanel,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onHideControls = { viewModel.toggleControlsVisibility() },
|
||||
onPausePlaybackWithoutShowingControls = pausePlaybackWithoutShowingControls,
|
||||
onResumePlaybackWithoutShowingControls = resumePlaybackWithoutShowingControls,
|
||||
onSeekRelative = seekByWithoutShowingControls,
|
||||
onShowControls = showTvControls,
|
||||
onTogglePlayPause = togglePlayPauseAndShowControls
|
||||
)
|
||||
.focusRequester(rootFocusRequester)
|
||||
.onKeyEvent { event ->
|
||||
if (event.key == Key.Back) {
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
handleBack()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
val handled = handleTvPlayerRootKeyEvent(
|
||||
event = event,
|
||||
controlsVisible = controlsVisible,
|
||||
popupVisible = showSkipIntroButton || showNextEpisodeOverlay,
|
||||
onShowControls = ::showControls,
|
||||
onTogglePlayback = {
|
||||
resumeStopFeedbackCounter++
|
||||
viewModel.togglePlayPause()
|
||||
},
|
||||
onSeekRelative = {
|
||||
hiddenSeekCounter++
|
||||
viewModel.seekBy(it)
|
||||
},
|
||||
)
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
handled
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
) {
|
||||
@@ -266,6 +278,8 @@ fun TvPlayerScreen(
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
player = viewModel.player
|
||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
isFocusable = false
|
||||
isFocusableInTouchMode = false
|
||||
}
|
||||
},
|
||||
update = {
|
||||
@@ -278,7 +292,7 @@ fun TvPlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
@@ -290,21 +304,25 @@ fun TvPlayerScreen(
|
||||
qualityButtonFocusRequester = qualityButtonFocusRequester,
|
||||
audioButtonFocusRequester = audioButtonFocusRequester,
|
||||
subtitlesButtonFocusRequester = subtitlesButtonFocusRequester,
|
||||
onPlayPause = togglePlayPauseAndShowControls,
|
||||
onSeek = seekAndShowControls,
|
||||
onSeekRelative = seekByAndShowControls,
|
||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||
onSkipSegment = skipSegmentAndShowControls,
|
||||
onNext = nextAndShowControls,
|
||||
onPrevious = previousAndShowControls,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
onSeek = { positionMs ->
|
||||
viewModel.seekTo(positionMs)
|
||||
},
|
||||
onSeekRelative = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
},
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||
onOpenSubtitlesPanel = { trackPanelType = TvTrackPanelType.SUBTITLES },
|
||||
onOpenQualityPanel = { trackPanelType = TvTrackPanelType.QUALITY },
|
||||
onExpandPlaylist = expandPlaylist,
|
||||
onCollapsePlaylist = collapsePlaylistToControls,
|
||||
onExpandPlaylist = ::expandPlaylist,
|
||||
onCollapsePlaylist = ::closePlaylist,
|
||||
onSelectQueueItem = { id ->
|
||||
viewModel.playQueueItem(id)
|
||||
collapsePlaylistToControls()
|
||||
},
|
||||
qualityButtonEnabled = uiState.qualityTracks.isNotEmpty(),
|
||||
audioButtonEnabled = uiState.audioTracks.isNotEmpty(),
|
||||
@@ -313,42 +331,89 @@ fun TvPlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
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)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = skipSegmentAndShowControls,
|
||||
onClick = { viewModel.skipActiveSegment() },
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 32.dp,
|
||||
bottom = 140.dp
|
||||
)
|
||||
) {
|
||||
uiState.nextEpisode?.let { nextEpisode ->
|
||||
TvNextEpisodeOverlay(
|
||||
nextEpisode = nextEpisode,
|
||||
onClick = { viewModel.next() },
|
||||
modifier = Modifier.focusRequester(nextEpisodeFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!showSkipIntroButton && !controlsVisible) {
|
||||
ValueChangeTimedVisibility(
|
||||
value = hiddenSeekCounter,
|
||||
hideAfterMillis = 2500L,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||
) {
|
||||
HiddenTvSeekTimeline(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TvPlayerLoadingErrorEndCard(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
onRetry = { viewModel.retry() },
|
||||
onNext = nextAndShowControls,
|
||||
onNext = { viewModel.next() },
|
||||
onReplay = {
|
||||
viewModel.seekTo(0L)
|
||||
togglePlayPauseAndShowControls()
|
||||
viewModel.resumePlayback()
|
||||
},
|
||||
onDismissError = { viewModel.clearError() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = stopFeedbackVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
ValueChangeTimedVisibility(
|
||||
value = resumeStopFeedbackCounter,
|
||||
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerHiddenStopFeedback()
|
||||
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -372,43 +437,74 @@ fun TvPlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
isPlaying: Boolean,
|
||||
controlsVisible: Boolean,
|
||||
isPlaylistExpanded: Boolean,
|
||||
trackPanelType: TvTrackPanelType?,
|
||||
onCloseTrackPanel: () -> Unit,
|
||||
onCollapsePlaylist: () -> Unit,
|
||||
onHideControls: () -> Unit,
|
||||
onPausePlaybackWithoutShowingControls: () -> Unit,
|
||||
onResumePlaybackWithoutShowingControls: () -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onShowControls: () -> Unit,
|
||||
onTogglePlayPause: () -> Unit
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
@Composable
|
||||
private fun HiddenTvSeekTimeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
|
||||
if (event.key == Key.Back || event.key == Key.Escape) {
|
||||
return when {
|
||||
trackPanelType != null -> {
|
||||
onCloseTrackPanel()
|
||||
true
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
TvPlayerTimeRow(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = positionMs,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp),
|
||||
isFocused = false,
|
||||
thumbRadius = 7.dp,
|
||||
focusedThumbRadius = 9.dp,
|
||||
focusedThumbHaloRadiusDelta = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isPlaylistExpanded -> {
|
||||
onCollapsePlaylist()
|
||||
true
|
||||
}
|
||||
|
||||
controlsVisible -> {
|
||||
onHideControls()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
@Composable
|
||||
private fun TvPlayerClock(modifier: Modifier = Modifier) {
|
||||
var currentTime by remember { mutableStateOf(LocalTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
currentTime = LocalTime.now()
|
||||
delay(1_000L)
|
||||
}
|
||||
}
|
||||
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
|
||||
Text(
|
||||
text = formatter.format(currentTime),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: KeyEvent,
|
||||
controlsVisible: Boolean,
|
||||
popupVisible: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onShowControls: () -> Unit,
|
||||
): Boolean {
|
||||
if (event.type != KeyEventType.KeyDown) return false
|
||||
|
||||
if (!controlsVisible) {
|
||||
return when (event.key) {
|
||||
@@ -418,7 +514,7 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onSeekRelative(10_000)
|
||||
onSeekRelative(30_000)
|
||||
true
|
||||
}
|
||||
|
||||
@@ -428,12 +524,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
Key.DirectionCenter, Key.Enter -> {
|
||||
if (isPlaying) {
|
||||
onPausePlaybackWithoutShowingControls()
|
||||
if (popupVisible) {
|
||||
// Do nothing because the focused component is not he root, but an overlay
|
||||
false
|
||||
} else {
|
||||
onResumePlaybackWithoutShowingControls()
|
||||
onTogglePlayback()
|
||||
true
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
@@ -444,9 +541,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerHiddenStopFeedback(
|
||||
internal fun TvPlayerStopFeedback(
|
||||
stopped: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!stopped) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||
@@ -457,7 +558,7 @@ internal fun TvPlayerHiddenStopFeedback(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Pause,
|
||||
contentDescription = "Pause playback",
|
||||
contentDescription = "Play/Pause playback",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ 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.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -55,62 +54,74 @@ internal fun TvIconButton(
|
||||
targetValue = if (isFocused) scheme.primary else Color.Transparent,
|
||||
label = "border"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isFocused) {
|
||||
scheme.primaryContainer
|
||||
} else {
|
||||
scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "background"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.onPrimaryContainer else scheme.onSurface,
|
||||
label = "content"
|
||||
)
|
||||
val shape = RoundedCornerShape(50)
|
||||
val iconSize = (size - 24).coerceAtLeast(0).dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
val buttonModifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.focusProperties { canFocus = enabled }
|
||||
.semantics {
|
||||
if (!enabled) {
|
||||
disabled()
|
||||
}
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.widthIn(min = if (label == null) size.dp else 104.dp)
|
||||
.height(size.dp)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 0.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.5f)
|
||||
else scheme.background.copy(alpha = 0.65f)
|
||||
)
|
||||
.focusProperties { canFocus = enabled }
|
||||
.semantics {
|
||||
if (!enabled) {
|
||||
disabled()
|
||||
}
|
||||
}
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (label == null) {
|
||||
}
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(enabled = enabled) { onClick() }
|
||||
|
||||
if (label == null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = contentColor,
|
||||
modifier = buttonModifier
|
||||
.size(size.dp)
|
||||
.padding(12.dp)
|
||||
.size(iconSize)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = buttonModifier
|
||||
.widthIn(min = 104.dp)
|
||||
.height(size.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onBackground,
|
||||
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.shrinkVertically
|
||||
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
|
||||
@@ -17,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Forward30
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Replay10
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -47,7 +44,7 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
internal const val TvPlayerPlaylistStateTag = "tv_player_playlist_state"
|
||||
internal const val TvPlayerPlayPauseButtonTag = "tv_player_play_pause_button"
|
||||
@@ -64,7 +61,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
@@ -110,7 +106,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
@@ -173,7 +168,6 @@ private fun TvPlayerBottomSection(
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
@@ -188,7 +182,6 @@ private fun TvPlayerBottomSection(
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val playlistFocusRequester = remember { FocusRequester() }
|
||||
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
||||
|
||||
@@ -211,60 +204,11 @@ private fun TvPlayerBottomSection(
|
||||
.testTag(TvPlayerPlaylistStateTag)
|
||||
.semantics { stateDescription = playlistExpandState }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = onSkipSegment,
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
TvPlayerTimeRow(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
TvPlayerSeekBar(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
@@ -295,42 +239,28 @@ private fun TvPlayerBottomSection(
|
||||
icon = Icons.Outlined.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
onClick = onPrevious,
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
onClick = { onSeekRelative(-10_000) },
|
||||
size = 64,
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
onClick = onPlayPause,
|
||||
size = 72,
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(TvPlayerPlayPauseButtonTag)
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Forward30,
|
||||
contentDescription = "Seek forward 30 seconds",
|
||||
onClick = { onSeekRelative(30_000) },
|
||||
size = 64,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
onClick = onNext,
|
||||
size = 64,
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -356,6 +286,22 @@ private fun TvPlayerBottomSection(
|
||||
.testTag(TvPlayerSubtitlesButtonTag)
|
||||
)
|
||||
}
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Skip segment",
|
||||
onClick = onSkipSegment,
|
||||
size = 64,
|
||||
label = "Skip",
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isPlaylistExpanded,
|
||||
@@ -376,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(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
onExpand: () -> Unit
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerLoadingErrorEndCard(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -7,14 +9,19 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -25,11 +32,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
@@ -42,8 +52,8 @@ 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.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.player.model.PlaylistElementUiModel
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.PlaylistElementUiModel
|
||||
|
||||
internal const val TvPlayerPlaylistRowTag = "tv_player_playlist_row"
|
||||
internal const val TvPlayerPlaylistCurrentItemTag = "tv_player_playlist_current_item"
|
||||
@@ -82,75 +92,56 @@ internal fun TvPlayerQueuePanel(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surface.copy(alpha = 0.94f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Playlist",
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = queueCountLabel,
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.queue.isEmpty()) {
|
||||
Text(
|
||||
text = "Add something to the queue to browse it here.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TvPlayerPlaylistRowTag),
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(end = 4.dp)
|
||||
) {
|
||||
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
||||
val isEntryItem = index == entryIndex
|
||||
TvQueueRowCard(
|
||||
item = item,
|
||||
isCurrent = item.isCurrent,
|
||||
isFirst = index == 0,
|
||||
isLast = index == uiState.queue.lastIndex,
|
||||
onClick = { onSelect(item.id) },
|
||||
onReturnToControls = onReturnToControls,
|
||||
modifier = Modifier
|
||||
.width(228.dp)
|
||||
.then(
|
||||
if (isEntryItem) {
|
||||
Modifier
|
||||
.focusRequester(firstItemFocusRequester)
|
||||
.testTag(TvPlayerPlaylistCurrentItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
if (uiState.queue.isEmpty()) {
|
||||
Text(
|
||||
text = "Add something to the queue to browse it here.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TvPlayerPlaylistRowTag),
|
||||
state = listState,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(16.dp)
|
||||
) {
|
||||
itemsIndexed(uiState.queue, key = { _, item -> item.id }) { index, item ->
|
||||
val isEntryItem = index == entryIndex
|
||||
TvQueueRowCard(
|
||||
item = item,
|
||||
isCurrent = item.isCurrent,
|
||||
isFirst = index == 0,
|
||||
isLast = index == uiState.queue.lastIndex,
|
||||
onClick = { onSelect(item.id) },
|
||||
onReturnToControls = onReturnToControls,
|
||||
modifier = Modifier
|
||||
.width(228.dp)
|
||||
.then(
|
||||
if (isEntryItem) {
|
||||
Modifier
|
||||
.focusRequester(firstItemFocusRequester)
|
||||
.testTag(TvPlayerPlaylistCurrentItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistFirstItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == uiState.queue.lastIndex && !isEntryItem) {
|
||||
Modifier.testTag(TvPlayerPlaylistLastItemTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,25 +160,49 @@ private fun TvQueueRowCard(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val borderColor = when {
|
||||
isFocused -> scheme.primary
|
||||
isCurrent -> scheme.primary.copy(alpha = 0.55f)
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
val backgroundColor = when {
|
||||
isFocused -> scheme.primary.copy(alpha = 0.18f)
|
||||
isCurrent -> scheme.surfaceVariant.copy(alpha = 0.78f)
|
||||
else -> scheme.surfaceVariant.copy(alpha = 0.62f)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.06f else 1f,
|
||||
label = "queueCardScale"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primary
|
||||
isCurrent -> scheme.tertiary
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
label = "queueCardBorder"
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primaryContainer.copy(alpha = 0.96f)
|
||||
isCurrent -> scheme.tertiaryContainer.copy(alpha = 0.86f)
|
||||
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "queueCardBackground"
|
||||
)
|
||||
val contentColor = when {
|
||||
isFocused -> scheme.onPrimaryContainer
|
||||
isCurrent -> scheme.onTertiaryContainer
|
||||
else -> scheme.onSurface
|
||||
}
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
width = when {
|
||||
isFocused -> 3.dp
|
||||
isCurrent -> 2.dp
|
||||
else -> 1.dp
|
||||
},
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
shape = shape
|
||||
)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
@@ -207,37 +222,34 @@ private fun TvQueueRowCard(
|
||||
}
|
||||
}
|
||||
.clickable { onClick() }
|
||||
.padding(12.dp),
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
TvQueueArtwork(
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = isCurrent,
|
||||
badgeColor = if (isFocused) scheme.primary else scheme.tertiary,
|
||||
badgeContentColor = if (isFocused) scheme.onPrimary else scheme.onTertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isCurrent) {
|
||||
Text(
|
||||
text = "Current",
|
||||
color = scheme.primary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isCurrent || isFocused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvQueueArtwork(
|
||||
artworkUrl: String?,
|
||||
isCurrent: Boolean,
|
||||
badgeColor: Color,
|
||||
badgeContentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -268,5 +280,30 @@ private fun TvQueueArtwork(
|
||||
.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.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerSeekBar(
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.ClosedCaption
|
||||
import androidx.compose.material.icons.outlined.HighQuality
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -44,8 +49,8 @@ import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
|
||||
internal const val TvPlayerQualityButtonTag = "tv_player_quality_button"
|
||||
internal const val TvPlayerAudioButtonTag = "tv_player_audio_button"
|
||||
@@ -211,23 +216,42 @@ private fun TvTrackOptionRow(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primaryContainer
|
||||
selected -> scheme.secondaryContainer.copy(alpha = 0.9f)
|
||||
else -> scheme.surfaceContainerHigh.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "trackOptionBackground"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.onPrimaryContainer
|
||||
selected -> scheme.onSecondaryContainer
|
||||
else -> scheme.onSurface
|
||||
},
|
||||
label = "trackOptionContent"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primary
|
||||
selected -> scheme.secondary
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.45f)
|
||||
},
|
||||
label = "trackOptionBorder"
|
||||
)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, scheme.primary, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.3f)
|
||||
else if (selected) scheme.primary.copy(alpha = 0.15f)
|
||||
else scheme.surfaceVariant.copy(alpha = 0.6f)
|
||||
.border(
|
||||
width = if (isFocused) 3.dp else if (selected) 2.dp else 1.dp,
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
@@ -244,11 +268,25 @@ private fun TvTrackOptionRow(
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (selected || isFocused) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,54 +2,62 @@ package hu.bbara.purefin.ui.screen.series
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
||||
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import java.util.UUID
|
||||
import androidx.tv.material3.Text as TvText
|
||||
|
||||
@Composable
|
||||
fun TvSeriesScreen(
|
||||
series: SeriesDto,
|
||||
focusedSeasonId: UUID? = null,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(series.id) {
|
||||
viewModel.selectSeries(series.id)
|
||||
LaunchedEffect(series) {
|
||||
viewModel.selectSeries(series)
|
||||
}
|
||||
|
||||
val series = viewModel.series.collectAsState()
|
||||
val series = viewModel.series.collectAsStateWithLifecycle()
|
||||
|
||||
val seriesData = series.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
if (seriesData != null) {
|
||||
TvSeriesScreenContent(
|
||||
series = seriesData,
|
||||
selectSeason = viewModel::selectSeason,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
focusedSeasonId = focusedSeasonId,
|
||||
focusedEpisodeId = focusedEpisodeId,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
@@ -60,93 +68,105 @@ fun TvSeriesScreen(
|
||||
@Composable
|
||||
internal fun TvSeriesScreenContent(
|
||||
series: Series,
|
||||
selectSeason: (UUID, UUID) -> Unit,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
focusedSeasonId: UUID? = null,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val firstContentFocusRequester = remember { FocusRequester() }
|
||||
val defaultSeason = series.defaultSeason(focusedSeasonId)
|
||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||
mutableStateOf(defaultSeason?.id)
|
||||
}
|
||||
val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val initialFocusSeasonId = defaultSeason?.id
|
||||
val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
|
||||
series.allSeasons.firstOrNull { it.id == seasonId }
|
||||
} ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
|
||||
initialFocusSeason?.episodes?.isEmpty() == true &&
|
||||
initialFocusSeason.episodeCount > 0
|
||||
|
||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||
withFrameNanos { }
|
||||
if (nextUpEpisode != null) {
|
||||
playFocusRequester.requestFocus()
|
||||
} else {
|
||||
firstContentFocusRequester.requestFocus()
|
||||
}
|
||||
LaunchedEffect(series.id, selectedSeason?.id) {
|
||||
selectedSeason?.let { selectSeason(series.id, it.id) }
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
resetScrollKey = series.id,
|
||||
modifier = modifier
|
||||
) {
|
||||
item(key = "series-hero") {
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||
modifier = it
|
||||
TvMediaDetailBodyBox(
|
||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
heightFraction = 1f
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
) {
|
||||
TvSeriesHeroSection(
|
||||
series = series,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
playFocusRequester = playFocusRequester,
|
||||
firstContentFocusRequester = firstContentFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
item(key = "series-overview") {
|
||||
Column(modifier = it.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaDetailOverviewSection(
|
||||
synopsis = series.synopsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
item(key = "series-season-tabs") {
|
||||
TvSeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeason = it },
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
item(key = "series-episodes") {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
if (series.cast.isNotEmpty()) {
|
||||
item(key = "series-cast") {
|
||||
Column(modifier = it) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
CastRow(cast = series.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (selectedSeason != null) {
|
||||
TvSeasonTabs(
|
||||
seasons = series.allSeasons,
|
||||
selectedSeason = selectedSeason,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
requestSelectedItemFocus = initialFocusedEpisodeId == null &&
|
||||
!waitingForInitialEpisodes &&
|
||||
!requestedInitialSeasonFocus,
|
||||
onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
focusedEpisodeId = initialFocusedEpisodeId,
|
||||
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
|
||||
onFocusRequested = { requestedInitialEpisodeFocus = true },
|
||||
upFocusRequester = seasonTabFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
TvText(
|
||||
text = "Loading seasons...",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(): Season {
|
||||
for (season in seasons) {
|
||||
if (season.episodes.any { !it.watched }) {
|
||||
return season
|
||||
}
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
|
||||
if (focusedSeasonId != null) {
|
||||
allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
}
|
||||
return seasons.first()
|
||||
|
||||
return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
||||
season.episodes.firstOrNull { !it.watched }
|
||||
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||
private fun Season.nextUpEpisode(): Episode? {
|
||||
return episodes.firstOrNull { !it.watched } ?: episodes.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Season.focusTargetEpisodeId(focusedEpisodeId: UUID?): UUID? {
|
||||
val focusedEpisode = episodes.firstOrNull { it.id == focusedEpisodeId }
|
||||
return focusedEpisode?.id ?: nextUpEpisode()?.id
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package hu.bbara.purefin.ui.screen.series.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,13 +18,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
@@ -30,17 +30,20 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -50,24 +53,22 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||
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 SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
|
||||
|
||||
@Composable
|
||||
internal fun TvSeriesMetaChips(series: Series) {
|
||||
@@ -77,29 +78,55 @@ internal fun TvSeriesMetaChips(series: Series) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun TvSeasonTabs(
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
selectedItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
requestSelectedItemFocus: Boolean = false,
|
||||
onSelectedItemFocusRequested: () -> 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
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
.then(
|
||||
if (selectedItemFocusRequester != null) {
|
||||
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(
|
||||
name = season.name,
|
||||
isSelected = season == selectedSeason,
|
||||
onSelect = { onSelect(season) },
|
||||
isSelected = index == selectedSeasonIndex,
|
||||
onFocused = { onSelect(season) },
|
||||
onClick = { onSelect(season) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
|
||||
Modifier.focusRequester(selectedItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -120,58 +147,128 @@ internal fun TvSeasonTabs(
|
||||
private fun TvSeasonTab(
|
||||
name: String,
|
||||
isSelected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val color = if (isSelected || isFocused) scheme.primary else mutedStrong
|
||||
val underlineColor = if (isSelected || isFocused) scheme.primary else Color.Transparent
|
||||
val underlineHeight = if (isFocused) 3.dp else 2.dp
|
||||
val color = if (isSelected) scheme.primary else scheme.onSurface
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { onSelect() }
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) {
|
||||
onFocused()
|
||||
isFocused.value = true
|
||||
} else {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.borderIfFocused(isFocused.value, MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Text(
|
||||
TvText(
|
||||
text = name,
|
||||
color = color,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.height(underlineHeight)
|
||||
.width(52.dp)
|
||||
.background(underlineColor)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@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 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) {
|
||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||
if (firstUnwatchedIndex != 0) {
|
||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
||||
LaunchedEffect(episodes, targetEpisodeId) {
|
||||
if (episodes.none { it.id == rowFocusedEpisodeId }) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
}
|
||||
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||
TvEpisodeCard(episode = episode)
|
||||
LaunchedEffect(requestFocus, targetEpisodeId) {
|
||||
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
|
||||
rowFocusedEpisodeId = targetEpisodeId
|
||||
if (targetEpisodeIndex > 0) {
|
||||
listState.scrollToItem(targetEpisodeIndex)
|
||||
} else {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
withFrameNanos { }
|
||||
rowScopedFocusRequester.requestFocus()
|
||||
requestedFocusEpisodeId = targetEpisodeId
|
||||
onFocusRequested()
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.focusRestorer(rowScopedFocusRequester)
|
||||
.focusGroup(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||
TvEpisodeCard(
|
||||
episode = episode,
|
||||
onPlayEpisode = { onPlayEpisode(episode) },
|
||||
onFocused = { rowFocusedEpisodeId = episode.id },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (episode.id == rowFocusedEpisodeId) {
|
||||
Modifier.focusRequester(rowScopedFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(upFocusRequester?.let { requester ->
|
||||
Modifier.focusProperties { up = requester }
|
||||
} ?: Modifier)
|
||||
.then(
|
||||
if (episode.id == targetEpisodeId) {
|
||||
Modifier.testTag(SeriesNextUpEpisodeCardTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,10 +276,6 @@ internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Mod
|
||||
@Composable
|
||||
internal fun TvSeriesHeroSection(
|
||||
series: Series,
|
||||
nextUpEpisode: Episode?,
|
||||
onPlayEpisode: (Episode) -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
firstContentFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -190,81 +283,46 @@ internal fun TvSeriesHeroSection(
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
lineHeight = 28.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TvSeriesMetaChips(series = series)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (nextUpEpisode != null) {
|
||||
Text(
|
||||
text = nextUpEpisode.heroStatusText(),
|
||||
color = scheme.primary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = nextUpEpisode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}",
|
||||
color = mutedStrong,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = 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
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Overview",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = series.synopsis,
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvEpisodeCard(
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
episode: Episode
|
||||
episode: Episode,
|
||||
onPlayEpisode: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
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")
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.width(260.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable { viewModel.onSelectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
) },
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.clickable { onPlayEpisode() },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
@@ -337,7 +394,6 @@ private fun TvEpisodeCard(
|
||||
} else {
|
||||
WatchStateBadge(
|
||||
watched = episode.watched,
|
||||
started = (episode.progress ?: 0.0) > 0.0,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
@@ -376,6 +432,14 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
// )
|
||||
}
|
||||
|
||||
private fun Episode.heroStatusText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
||||
}
|
||||
private fun Modifier.borderIfFocused(isFocused: Boolean, borderColor: Color): Modifier =
|
||||
if (isFocused) {
|
||||
this.border(
|
||||
width = 2.dp,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.feature.settings.SettingsViewModel
|
||||
import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.ReadOnlySetting
|
||||
import hu.bbara.purefin.core.settings.SettingOption
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TvSettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit = viewModel::onBack,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(viewModel, context) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
TvSettingsTopBar(onBack = onBack)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
settingGroups.forEach { group ->
|
||||
group.title?.let { title ->
|
||||
item {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
group.options.forEach { option ->
|
||||
item(key = option.key) {
|
||||
TvSettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSettingOptionItem(
|
||||
option: SettingOption<*>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
when (option) {
|
||||
is RangeSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvNumberSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BooleanSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvBooleanSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is StringSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvStringSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReadOnlySetting -> {
|
||||
TvReadOnlySettingItem(
|
||||
title = option.title,
|
||||
value = option.value
|
||||
)
|
||||
}
|
||||
|
||||
is VoidSetting -> {
|
||||
TvVoidSettingItem(
|
||||
title = option.title,
|
||||
onClick = { viewModel.onClick(option) }
|
||||
)
|
||||
}
|
||||
|
||||
is DropdownSetting<*> -> {
|
||||
TvDropdownSettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> TvDropdownSettingOptionItem(
|
||||
option: DropdownSetting<T>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
TvDropdownSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
options = option.options,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvSettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvReadOnlySettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvVoidSettingItem(
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> TvDropdownSettingItem(
|
||||
title: String,
|
||||
value: T,
|
||||
options: List<T>,
|
||||
onValueChange: (T) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true }
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.toString()) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onValueChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvNumberSettingItem(
|
||||
title: String,
|
||||
value: Double,
|
||||
valueRange: ClosedFloatingPointRange<Double>,
|
||||
onValueChange: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.US, "%.1f", sliderValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
|
||||
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvBooleanSettingItem(
|
||||
title: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onValueChange(!value) }
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onValueChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvStringSettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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.MediaKind
|
||||
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.NextUpItem
|
||||
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.Test
|
||||
import java.util.UUID
|
||||
@@ -95,7 +95,9 @@ class TvFocusedItemHeroTest {
|
||||
return Episode(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||
seriesName = "Love, Death & Robots",
|
||||
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||
seasonIndex = 3,
|
||||
index = 3,
|
||||
title = "The Very Pulse of the Machine",
|
||||
synopsis = "An astronaut faces a strange world alone.",
|
||||
|
||||
@@ -5,7 +5,7 @@ import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.model.MediaKind
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
|
||||
import hu.bbara.purefin.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.PosterItem
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -104,12 +104,13 @@ class TvHomeHeroStateTest {
|
||||
assertEquals(continueWatching, registry.firstAvailableItem)
|
||||
}
|
||||
|
||||
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem {
|
||||
return LibraryItem(
|
||||
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryUiModel {
|
||||
return LibraryUiModel(
|
||||
id = id,
|
||||
name = "Movies",
|
||||
type = LibraryKind.MOVIES,
|
||||
posterUrl = "",
|
||||
size = 1,
|
||||
isEmpty = isEmpty
|
||||
)
|
||||
}
|
||||
@@ -126,7 +127,9 @@ class TvHomeHeroStateTest {
|
||||
episode = Episode(
|
||||
id = UUID.fromString(id),
|
||||
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
seriesName = "Sample Series",
|
||||
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
seasonIndex = 1,
|
||||
index = 1,
|
||||
title = "Next Episode",
|
||||
synopsis = "Episode synopsis",
|
||||
|
||||
@@ -11,24 +11,29 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "hu.bbara.purefin"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1000002
|
||||
versionName = "0.1"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app-debug/update.json"
|
||||
}
|
||||
release {
|
||||
// Enables code-related app optimization.
|
||||
isMinifyEnabled = true
|
||||
|
||||
// Enables resource shrinking.
|
||||
isShrinkResources = true
|
||||
manifestPlaceholders["updateManifestUrl"] = "https://apks.t.bbara.hu/apps/purefin-app/update.json"
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
@@ -40,6 +45,20 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
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(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purefin_logo_background">#201337</color>
|
||||
<color name="ic_launcher_foreground">#E53935</color>
|
||||
</resources>
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".PurefinApplication"
|
||||
@@ -12,11 +14,15 @@
|
||||
android:screenOrientation="portrait"
|
||||
android:dataExtractionRules="@xml/data_extraction_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:roundIcon="@mipmap/purefin_logo_round"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="${updateManifestUrl}" />
|
||||
<activity
|
||||
android:name=".PurefinActivity"
|
||||
android:exported="true"
|
||||
@@ -33,9 +39,12 @@
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Purefin" />
|
||||
<service
|
||||
android:name="hu.bbara.purefin.download.PurefinDownloadService"
|
||||
android:name="hu.bbara.purefin.core.download.PurefinDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<receiver
|
||||
android:name="hu.bbara.purefin.core.update.AppUpdateInstallReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -2,10 +2,13 @@ package hu.bbara.purefin
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -14,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
@@ -36,14 +41,18 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import hu.bbara.purefin.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.navigation.NavigationCommand
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||
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.login.LoginScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
@@ -131,6 +140,7 @@ class PurefinActivity : ComponentActivity() {
|
||||
return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun MainApp(
|
||||
userSessionRepository: UserSessionRepository,
|
||||
@@ -138,7 +148,8 @@ class PurefinActivity : ComponentActivity() {
|
||||
navigationManager: NavigationManager
|
||||
) {
|
||||
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) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
@@ -147,13 +158,14 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
if (!sessionLoaded) {
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
} else if (isLoggedIn) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
||||
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
|
||||
val appEntryProvider =
|
||||
entryProvider {
|
||||
entryBuilders.forEach { builder -> builder() }
|
||||
@@ -162,7 +174,13 @@ class PurefinActivity : ComponentActivity() {
|
||||
LaunchedEffect(navigationManager, backStack) {
|
||||
navigationManager.commands.collect { command ->
|
||||
when (command) {
|
||||
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
||||
NavigationCommand.Pop -> {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeLastOrNull()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
}
|
||||
is NavigationCommand.Navigate -> backStack.add(command.route)
|
||||
is NavigationCommand.ReplaceAll -> {
|
||||
backStack.clear()
|
||||
@@ -172,48 +190,80 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
LocalNavigationBackStack provides backStack.toList()
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
animationSpec = tween(
|
||||
durationMillis = 140
|
||||
)
|
||||
) togetherWith
|
||||
fadeOut(animationSpec = tween(durationMillis = 140))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
fadeIn(
|
||||
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
|
||||
)
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalNavigationManager provides navigationManager,
|
||||
LocalNavigationBackStack provides backStack.toList(),
|
||||
LocalSharedTransitionScope provides this,
|
||||
LocalHomeMediaSharedBoundsKey provides homeMediaSharedBoundsKey,
|
||||
LocalSetHomeMediaSharedBoundsKey provides { key ->
|
||||
homeMediaSharedBoundsKey = key
|
||||
}
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
if (backStack.size > 1) {
|
||||
navigationManager.pop()
|
||||
} else {
|
||||
showExitDialog = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundDark)
|
||||
.semantics { testTagsAsResourceId = true },
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
animationSpec = tween(
|
||||
durationMillis = 140
|
||||
)
|
||||
) togetherWith
|
||||
fadeOut(animationSpec = tween(durationMillis = 140))
|
||||
},
|
||||
popTransitionSpec = {
|
||||
fadeIn(
|
||||
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 {
|
||||
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 dagger.hilt.android.HiltAndroidApp
|
||||
import hu.bbara.purefin.core.logging.PurefinLogger
|
||||
|
||||
@HiltAndroidApp
|
||||
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.android.components.ActivityRetainedComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.navigation.appRouteEntryBuilder
|
||||
|
||||
@Module
|
||||
|
||||
@@ -4,7 +4,7 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.data.PlaybackProfileFamily
|
||||
import hu.bbara.purefin.core.data.PlaybackProfileFamily
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
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.compositionLocalOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
|
||||
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
|
||||
staticCompositionLocalOf { error("NavigationManager not provided") }
|
||||
|
||||
val LocalNavigationBackStack = compositionLocalOf<List<Route>> { emptyList() }
|
||||
|
||||
@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
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.login.LoginScreen
|
||||
import hu.bbara.purefin.ui.screen.AppScreen
|
||||
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.settings.SettingsScreen
|
||||
import hu.bbara.purefin.ui.screen.series.SeriesScreen
|
||||
|
||||
fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||
entry<Route.Home> {
|
||||
AppScreen()
|
||||
CompositionLocalProvider(
|
||||
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||
) {
|
||||
AppScreen()
|
||||
}
|
||||
}
|
||||
entry<Route.HomeSearchRoute> {
|
||||
CompositionLocalProvider(
|
||||
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||
) {
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
entry<Route.MovieRoute> {
|
||||
MovieScreen(movie = it.item)
|
||||
CompositionLocalProvider(
|
||||
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||
) {
|
||||
MovieScreen(movie = it.item)
|
||||
}
|
||||
}
|
||||
entry<Route.SeriesRoute> {
|
||||
SeriesScreen(series = it.item)
|
||||
CompositionLocalProvider(
|
||||
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||
) {
|
||||
SeriesScreen(series = it.item)
|
||||
}
|
||||
}
|
||||
entry<Route.EpisodeRoute> {
|
||||
EpisodeScreen(episode = it.item)
|
||||
CompositionLocalProvider(
|
||||
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
|
||||
) {
|
||||
EpisodeScreen(episode = it.item)
|
||||
}
|
||||
}
|
||||
entry<Route.LibraryRoute> {
|
||||
LibraryScreen(library = it.library)
|
||||
@@ -27,4 +55,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
}
|
||||
entry<Route.SettingsRoute> {
|
||||
SettingsScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -11,12 +12,14 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.theme.AppTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PlayerActivity : ComponentActivity() {
|
||||
private val viewModel: PlayerViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
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.sp
|
||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.ui.model.SeriesUiModel
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import java.util.UUID
|
||||
@@ -117,7 +117,6 @@ fun PosterCardContent(
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(indicatorPadding),
|
||||
watched = model.watched,
|
||||
started = (model.progress ?: 0f) > 0f
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +27,8 @@ fun SectionHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
onActionClick: () -> Unit = {}
|
||||
) {
|
||||
val headerColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -36,10 +39,14 @@ fun SectionHeader(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = headerColor
|
||||
)
|
||||
if (actionLabel != null) {
|
||||
TextButton(onClick = onActionClick) {
|
||||
TextButton(
|
||||
onClick = onActionClick,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = headerColor)
|
||||
) {
|
||||
Text(text = actionLabel)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
||||
@Composable
|
||||
fun AppBottomBar(
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
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(
|
||||
selected = selectedTab == 0,
|
||||
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,
|
||||
selected = !isOnline || selectedTab == 2,
|
||||
onClick = { onTabSelected(2) },
|
||||
modifier = Modifier.testTag(BottomNavigationDownloadsTag),
|
||||
icon = { Icon(Icons.Outlined.Download, contentDescription = "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
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.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.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.scene.Scene
|
||||
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.ui.screen.download.DownloadsScreen
|
||||
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 kotlinx.serialization.Serializable
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val libraries by viewModel.libraries.collectAsState()
|
||||
val libraryContent by viewModel.latestLibraryContent.collectAsState()
|
||||
val suggestions by viewModel.suggestions.collectAsState()
|
||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||
val nextUp by viewModel.nextUp.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val libraries by viewModel.libraries.collectAsStateWithLifecycle()
|
||||
val libraryContent by viewModel.latestLibraryContent.collectAsStateWithLifecycle()
|
||||
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
|
||||
val continueWatching by viewModel.continueWatching.collectAsStateWithLifecycle()
|
||||
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
|
||||
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 snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
|
||||
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
|
||||
val selectedTab = currentRoute.toTabIndex()
|
||||
|
||||
val libraryNavItems = libraries.map {
|
||||
HomeNavItem(
|
||||
id = it.id,
|
||||
label = it.name,
|
||||
posterUrl = it.posterUrl
|
||||
)
|
||||
LaunchedEffect(isOnline, currentRoute, backStack) {
|
||||
if (!isOnline && currentRoute != AppTabRoute.Downloads) {
|
||||
backStack.clear()
|
||||
backStack.add(AppTabRoute.Downloads)
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
@@ -61,9 +69,19 @@ fun AppScreen(
|
||||
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 ->
|
||||
val route = selectedIndex.toAppTabRoute()
|
||||
val route = if (isOnline) selectedIndex.toAppTabRoute() else AppTabRoute.Downloads
|
||||
if (backStack.lastOrNull() != route) {
|
||||
backStack.add(route)
|
||||
}
|
||||
@@ -87,22 +105,27 @@ fun AppScreen(
|
||||
library.name
|
||||
)
|
||||
},
|
||||
onMarkWatched = viewModel::markAsWatched,
|
||||
onProfileClick = {},
|
||||
onSettingsClick = {},
|
||||
onCheckForUpdates = { updateViewModel.checkForUpdates() },
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = viewModel::openSettings,
|
||||
onLogoutClick = viewModel::logout,
|
||||
onSearchClick = viewModel::openSearch,
|
||||
snackbarHostState = snackbarHostState,
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
|
||||
LibrariesScreen(
|
||||
items = libraryNavItems,
|
||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
|
||||
onProfileClick = {},
|
||||
onSettingsClick = {},
|
||||
onLogoutClick = viewModel::logout,
|
||||
items = libraries,
|
||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) },
|
||||
onSearchClick = viewModel::openSearch,
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -110,12 +133,19 @@ fun AppScreen(
|
||||
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
|
||||
DownloadsScreen(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
isCheckingConnection = isCheckingConnection,
|
||||
onCheckConnection = viewModel::checkConnection,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size == 1) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
@@ -128,12 +158,20 @@ fun AppScreen(
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator()
|
||||
),
|
||||
transitionSpec = { appTabTransition() },
|
||||
popTransitionSpec = { appTabTransition() },
|
||||
predictivePopTransitionSpec = { _ -> appTabTransition() },
|
||||
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||
popTransitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||
predictivePopTransitionSpec = { _ -> fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
|
||||
entryProvider = tabEntryProvider,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
UpdateAvailableDialog(
|
||||
update = update,
|
||||
onAccept = updateViewModel::acceptUpdate,
|
||||
onDecline = updateViewModel::declineUpdate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -161,31 +199,41 @@ private fun Int.toAppTabRoute(): AppTabRoute = when (this) {
|
||||
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> =
|
||||
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())
|
||||
|
||||
private fun Map<String, Any>.appTabIndex(): Int =
|
||||
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"
|
||||
|
||||
@@ -2,16 +2,23 @@ package hu.bbara.purefin.ui.screen.download
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
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.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
|
||||
fun DownloadsScreen(
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
isCheckingConnection: Boolean,
|
||||
onCheckConnection: () -> Unit,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -19,9 +26,23 @@ fun DownloadsScreen(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
DefaultTopBar(
|
||||
rightActions = {
|
||||
if (!isOnline) {
|
||||
DefaultTopBarTextButton(
|
||||
text = if (isCheckingConnection) "Checking…" else "Check connection",
|
||||
onClick = onCheckConnection,
|
||||
enabled = !isCheckingConnection
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,20 +22,22 @@ 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.platform.testTag
|
||||
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.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.feature.downloads.ActiveDownloadItem
|
||||
import hu.bbara.purefin.core.feature.downloads.ActiveDownloadItem
|
||||
|
||||
@Composable
|
||||
internal fun DownloadingItemRow(
|
||||
item: ActiveDownloadItem,
|
||||
onCancel: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
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(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.feature.downloads.DownloadsViewModel
|
||||
|
||||
@Composable
|
||||
fun DownloadsContent(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: DownloadsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val downloads = viewModel.downloads.collectAsState(emptyList())
|
||||
val activeDownloads = viewModel.activeDownloads.collectAsState()
|
||||
val downloads = viewModel.downloads.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val activeDownloads = viewModel.activeDownloads.collectAsStateWithLifecycle()
|
||||
|
||||
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
|
||||
|
||||
if (isEmpty) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(DownloadsEmptyStateTag),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -62,7 +68,9 @@ fun DownloadsContent(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = 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()) {
|
||||
item(key = "downloading-header", span = { GridItemSpan(maxLineSpan) }) {
|
||||
@@ -80,7 +88,8 @@ fun DownloadsContent(
|
||||
) { item ->
|
||||
DownloadingItemRow(
|
||||
item = item,
|
||||
onCancel = { viewModel.cancelDownload(it) }
|
||||
onCancel = { viewModel.cancelDownload(it) },
|
||||
modifier = Modifier.testTag("$DownloadsActiveItemTagPrefix${item.contentId}")
|
||||
)
|
||||
}
|
||||
if (downloads.value.isNotEmpty()) {
|
||||
@@ -95,13 +104,22 @@ fun DownloadsContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
items(downloads.value, key = { item -> item.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
)
|
||||
items(downloads.value, key = { item -> item.media.id }) { item ->
|
||||
Column(
|
||||
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}")
|
||||
) {
|
||||
PosterCard(
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
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 android.content.Intent
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.common.media.MediaHero
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.download.DownloadState
|
||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
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.EpisodeTopBarShortcut
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -59,28 +51,18 @@ fun EpisodeScreen(
|
||||
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
||||
|
||||
LaunchedEffect(episode) {
|
||||
viewModel.selectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
)
|
||||
viewModel.selectEpisode(episode)
|
||||
}
|
||||
|
||||
val episode = viewModel.episode.collectAsState()
|
||||
val downloadState = viewModel.downloadState.collectAsState()
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// Proceed with download regardless — notification is nice-to-have
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||
val requestNotificationPermission = rememberNotificationPermissionGate()
|
||||
|
||||
val onDownloadClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& downloadState.value is DownloadState.NotDownloaded
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (downloadState.value is DownloadState.NotDownloaded) {
|
||||
requestNotificationPermission {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadClick()
|
||||
}
|
||||
@@ -101,121 +83,94 @@ fun EpisodeScreen(
|
||||
},
|
||||
downloadState = downloadState.value,
|
||||
onBack = viewModel::onBack,
|
||||
onSeriesClick = viewModel::onSeriesClick,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onMarkAsWatched = viewModel::markAsWatched,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EpisodeScreenInternal(
|
||||
episode: Episode,
|
||||
topBarShortcut: EpisodeTopBarShortcut?,
|
||||
downloadState: DownloadState,
|
||||
onBack: () -> Unit,
|
||||
onSeriesClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onMarkAsWatched: (Boolean) -> Unit = {},
|
||||
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,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
topBar = { scrollBehavior ->
|
||||
EpisodeTopBar(
|
||||
shortcut = topBarShortcut,
|
||||
onBack = onBack,
|
||||
onSeriesClick = onSeriesClick,
|
||||
modifier = Modifier
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { 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)
|
||||
@Composable
|
||||
private fun EpisodeScreenPreview() {
|
||||
@@ -225,7 +180,6 @@ private fun EpisodeScreenPreview() {
|
||||
topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}),
|
||||
downloadState = DownloadState.Downloading(progressPercent = 0.42f),
|
||||
onBack = {},
|
||||
onSeriesClick = {},
|
||||
onDownloadClick = {}
|
||||
)
|
||||
}
|
||||
@@ -237,7 +191,9 @@ private fun previewEpisode(): Episode {
|
||||
return Episode(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = seriesId,
|
||||
seriesName = "Severance",
|
||||
seasonId = seasonId,
|
||||
seasonIndex = 2,
|
||||
index = 4,
|
||||
title = "The You You Are",
|
||||
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue that reframes everything they thought they understood.",
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
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.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.MediaActionButton
|
||||
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
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
|
||||
internal sealed interface EpisodeTopBarShortcut {
|
||||
val label: String
|
||||
@@ -54,137 +32,60 @@ internal sealed interface EpisodeTopBarShortcut {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun EpisodeTopBar(
|
||||
shortcut: EpisodeTopBarShortcut?,
|
||||
onBack: () -> Unit,
|
||||
onSeriesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
when {
|
||||
shortcut != null -> {
|
||||
Box(
|
||||
when (shortcut) {
|
||||
is EpisodeTopBarShortcut.Series -> {
|
||||
TextButton(
|
||||
onClick = shortcut.onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(52.dp)
|
||||
.clickable(onClick = shortcut.onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
.testTag(EpisodeSeriesButtonTag)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
Text(
|
||||
text = shortcut.label,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
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.MoreVert, contentDescription = "More", onClick = { })
|
||||
}
|
||||
}
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.MoreVert,
|
||||
contentDescription = "More",
|
||||
onClick = { })
|
||||
},
|
||||
withIcon = false,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeDetails(
|
||||
episode: Episode,
|
||||
downloadState: DownloadState,
|
||||
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
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
internal const val EpisodeSeriesButtonTag = "episode-series-button"
|
||||
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||
internal const val EpisodeDownloadButtonTag = "episode-download-button"
|
||||
internal const val EpisodeWatchedButtonTag = "episode-watched-button"
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
package hu.bbara.purefin.ui.screen.home
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
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 hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.feature.browse.home.LibraryItem
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
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.home.components.HomeContent
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
suggestions: List<MediaUiModel>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
@@ -31,67 +29,61 @@ fun HomeScreen(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onMarkWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onCheckForUpdates: () -> Unit,
|
||||
isCheckingForUpdates: Boolean,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
HomeTopBar(
|
||||
onSearchClick = { isSearchVisible = true },
|
||||
onSearchClick = onSearchClick,
|
||||
onProfileClick = onProfileClick,
|
||||
onCheckForUpdates = onCheckForUpdates,
|
||||
isCheckingForUpdates = isCheckingForUpdates,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onLogoutClick = onLogoutClick
|
||||
onLogoutClick = onLogoutClick,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
suggestions = suggestions,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
onMediaSelected = onMediaSelected,
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onBrowseLibrariesClick = { onTabSelected(1) },
|
||||
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)
|
||||
)
|
||||
}
|
||||
HomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
suggestions = suggestions,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
onMediaSelected = onMediaSelected,
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onBrowseLibrariesClick = { onTabSelected(1) },
|
||||
onMarkAsWatched = onMarkWatched,
|
||||
modifier = Modifier.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.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.core.model.LibraryUiModel
|
||||
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.featured.SuggestionsSection
|
||||
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
|
||||
@@ -32,7 +33,7 @@ import java.util.UUID
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeContent(
|
||||
libraries: List<LibraryItem>,
|
||||
libraries: List<LibraryUiModel>,
|
||||
libraryContent: Map<UUID, List<MediaUiModel>>,
|
||||
suggestions: List<MediaUiModel>,
|
||||
continueWatching: List<MediaUiModel>,
|
||||
@@ -40,8 +41,9 @@ fun HomeContent(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onBrowseLibrariesClick: () -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -79,7 +81,9 @@ fun HomeContent(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = modifier.fillMaxSize()
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(HomeContentTag)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -88,7 +92,9 @@ fun HomeContent(
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag(HomeContentViewportTag),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
@@ -96,7 +102,8 @@ fun HomeContent(
|
||||
item(key = "featured") {
|
||||
SuggestionsSection(
|
||||
items = suggestions,
|
||||
onItemOpen = { item -> onMediaSelected(item) }
|
||||
onItemOpen = { item -> onMediaSelected(item) },
|
||||
modifier = Modifier.testTag(HomeFeaturedSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -105,17 +112,21 @@ fun HomeContent(
|
||||
item(key = "continue-watching") {
|
||||
ContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onMediaSelected = onMediaSelected
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag(HomeContinueWatchingSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (nextUp.isNotEmpty()) {
|
||||
item(key = "next-up") {
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onMediaSelected = onMediaSelected
|
||||
)
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag(HomeNextUpSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +138,9 @@ fun HomeContent(
|
||||
library = library,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,11 +148,20 @@ fun HomeContent(
|
||||
item(key = "empty-state") {
|
||||
HomeEmptyState(
|
||||
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.outlined.Collections
|
||||
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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,9 +32,9 @@ fun HomeEmptyState(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
Card(
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = scheme.surfaceContainerLow,
|
||||
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainerLow),
|
||||
modifier = modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
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
|
||||
|
||||
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.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
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.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)
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
onSearchClick: () -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onCheckForUpdates: () -> Unit,
|
||||
isCheckingForUpdates: Boolean,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
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(
|
||||
DefaultTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
rightActions = {
|
||||
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.Outlined.Person,
|
||||
contentDescription = "Profile",
|
||||
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(
|
||||
expanded = isProfileMenuExpanded,
|
||||
onDismissRequest = { isProfileMenuExpanded = false },
|
||||
@@ -111,6 +46,14 @@ fun HomeTopBar(
|
||||
onProfileClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isCheckingForUpdates) "Checking updates..." else "Check for updates") },
|
||||
enabled = !isCheckingForUpdates,
|
||||
onClick = {
|
||||
isProfileMenuExpanded = false
|
||||
onCheckForUpdates()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings") },
|
||||
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
|
||||
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.ui.Alignment
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.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
|
||||
internal fun ContinueWatchingCard(
|
||||
item: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||
onMediaSelected(item)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
MediaImageCard(
|
||||
imageUrl = item.primaryImageUrl,
|
||||
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)
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMediaSelected(item) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.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)
|
||||
)
|
||||
)
|
||||
.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,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = scheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
item.progress?.let { progress ->
|
||||
MediaProgressBar(
|
||||
progress = progress,
|
||||
foregroundColor = scheme.primary,
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.media.homeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
@Composable
|
||||
fun ContinueWatchingSection(
|
||||
items: List<MediaUiModel>,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
@@ -33,18 +38,27 @@ fun ContinueWatchingSection(
|
||||
modifier = modifier
|
||||
) {
|
||||
SectionHeader(title = "Continue Watching")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(HomeContinueWatchingRowTag),
|
||||
state = listState
|
||||
) {
|
||||
items(items = items, key = { item -> item.id }) { item ->
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
ContinueWatchingCard(
|
||||
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
|
||||
|
||||
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
|
||||
@@ -11,42 +10,49 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.Surface
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
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
|
||||
internal fun SuggestionCard(
|
||||
item: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val description = item.description.trim()
|
||||
val onCardClick = rememberHomeMediaSharedBoundsClick(
|
||||
sharedBoundsKey = sharedBoundsKey,
|
||||
onClick = onClick
|
||||
)
|
||||
|
||||
Surface(
|
||||
color = scheme.surfaceContainerLow,
|
||||
ElevatedCard(
|
||||
onClick = onCardClick,
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = scheme.surfaceContainerLow),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.clickable(onClick = onClick)
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.homeMediaSharedBoundsSource(sharedBoundsKey)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 11f)
|
||||
) {
|
||||
@@ -68,7 +74,7 @@ internal fun SuggestionCard(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -21,8 +21,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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
|
||||
fun SuggestionsSection(
|
||||
@@ -47,11 +49,16 @@ fun SuggestionsSection(
|
||||
pageSize = PageSize.Fixed(320.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
pageSpacing = 16.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(HomeFeaturedPagerTag)
|
||||
) { page ->
|
||||
val item = items[page]
|
||||
SuggestionCard(
|
||||
item = items[page],
|
||||
onClick = { onItemOpen(items[page]) }
|
||||
item = item,
|
||||
sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id),
|
||||
onClick = { onItemOpen(item) },
|
||||
modifier = Modifier.testTag("$HomeFeaturedItemTagPrefix$page")
|
||||
)
|
||||
}
|
||||
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
|
||||
|
||||
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.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.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.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import androidx.compose.runtime.remember
|
||||
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.ui.common.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
|
||||
internal fun HomeBrowseCard(
|
||||
uiModel: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||
onMediaSelected(uiModel)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||
buildList {
|
||||
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaImageCard(
|
||||
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)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMediaSelected(uiModel) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
)
|
||||
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))
|
||||
Column(modifier = modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = uiModel.primaryText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
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
|
||||
is SeriesUiModel -> {
|
||||
UnwatchedEpisodeBadge(
|
||||
unwatchedCount = uiModel.unwatchedEpisodeCount,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,29 @@ package hu.bbara.purefin.ui.screen.home.components.library
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.media.homeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
@Composable
|
||||
fun LibraryPosterSection(
|
||||
library: LibraryItem,
|
||||
library: LibraryUiModel,
|
||||
items: List<MediaUiModel>,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
@@ -40,18 +45,30 @@ fun LibraryPosterSection(
|
||||
actionLabel = "See all",
|
||||
onActionClick = { onLibrarySelected(library) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("$HomeLibraryRowTagPrefix${library.id}"),
|
||||
state = listState
|
||||
) {
|
||||
items(items = items, key = { item -> item.id }) { item ->
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
HomeBrowseCard(
|
||||
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
|
||||
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.remember
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.model.MediaUiModel
|
||||
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
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
|
||||
@Composable
|
||||
internal fun NextUpCard(
|
||||
uiModel: MediaUiModel,
|
||||
sharedBoundsKey: String,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
|
||||
onMediaSelected(uiModel)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
modifier = modifier.width(256.dp)
|
||||
) {
|
||||
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
|
||||
)
|
||||
val popupActions = remember(uiModel, onMarkAsWatched) {
|
||||
buildList {
|
||||
if (uiModel is MovieUiModel || uiModel is EpisodeUiModel || uiModel is SeriesUiModel) {
|
||||
add(MediaAction(name = "Mark as watched") { onMarkAsWatched(uiModel, true) })
|
||||
add(MediaAction(name = "Mark as unwatched") { onMarkAsWatched(uiModel, false) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaImageCard(
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
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.media.homeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
|
||||
@Composable
|
||||
fun NextUpSection(
|
||||
items: List<MediaUiModel>,
|
||||
onMediaSelected: (MediaUiModel) -> Unit,
|
||||
onMarkAsWatched: (MediaUiModel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
@@ -33,18 +38,27 @@ fun NextUpSection(
|
||||
modifier = modifier
|
||||
) {
|
||||
SectionHeader(title = "Next Up")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(HomeNextUpRowTag),
|
||||
state = listState
|
||||
) {
|
||||
items(items = items, key = { item -> item.id }) { item ->
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
NextUpCard(
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.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.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.feature.search.SearchViewModel
|
||||
import hu.bbara.purefin.core.feature.search.SearchViewModel
|
||||
import java.util.UUID
|
||||
import hu.bbara.purefin.model.MediaKind
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeSearchOverlay(
|
||||
fun SearchOverlay(
|
||||
visible: Boolean,
|
||||
topPadding: Dp,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -55,7 +55,7 @@ fun HomeSearchOverlay(
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val searchResults by searchViewModel.searchResult.collectAsState()
|
||||
val searchResults by searchViewModel.searchResult.collectAsStateWithLifecycle()
|
||||
val dismissInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
@@ -138,7 +138,7 @@ fun HomeSearchOverlay(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
|
||||
HomeSearchResultCard(
|
||||
SearchResultCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
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.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.ui.common.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.rememberDefaultTopBarScrollBehavior
|
||||
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
|
||||
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibrariesScreen(
|
||||
items: List<HomeNavItem>,
|
||||
onLibrarySelected: (HomeNavItem) -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
items: List<LibraryUiModel>,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val topBarScrollBehavior = rememberDefaultTopBarScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
DefaultTopBar(
|
||||
onProfileClick = onProfileClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onLogoutClick = onLogoutClick
|
||||
LibrariesTopBar(
|
||||
onSearchClick = onSearchClick,
|
||||
scrollBehavior = topBarScrollBehavior
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
package hu.bbara.purefin.ui.screen.libraries.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
|
||||
@Composable
|
||||
fun LibrariesContent(
|
||||
items: List<HomeNavItem>,
|
||||
onLibrarySelected: (HomeNavItem) -> Unit,
|
||||
items: List<LibraryUiModel>,
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = modifier,
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(items, key = { it.id }) { item ->
|
||||
LibraryListItem(
|
||||
item = item,
|
||||
modifier = Modifier.clickable {
|
||||
onLibrarySelected(item)
|
||||
}
|
||||
)
|
||||
BoxWithConstraints(modifier = modifier.testTag(LibrariesOverviewGridTag)) {
|
||||
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = minCellSize),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(items, key = { it.id }) { item ->
|
||||
LibraryListItem(
|
||||
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