mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
67 Commits
dddf6e4cfa
...
ef56db5d16
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,4 +18,5 @@
|
||||
local.properties
|
||||
.codex/
|
||||
openspec
|
||||
.opencode
|
||||
*.patch
|
||||
|
||||
@@ -72,7 +72,6 @@ When asked to prepare a commit:
|
||||
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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ android {
|
||||
applicationId = "hu.bbara.purefin.tv"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 2000004
|
||||
versionCode = providers.gradleProperty("purefinTvVersionCode").get().toInt()
|
||||
versionName = "0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -53,11 +53,13 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":data"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":core-model"))
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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
|
||||
@@ -21,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
|
||||
|
||||
fun EntryProviderScope<Route>.tvLoginSection() {
|
||||
entry<Route.LoginRoute> {
|
||||
LoginScreen()
|
||||
TvLoginScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
|
||||
fun EntryProviderScope<Route>.tvEpisodeSection() {
|
||||
entry<Route.EpisodeRoute> { route ->
|
||||
TvSeriesScreen(
|
||||
series = SeriesDto(id = route.item.seriesId),
|
||||
series = SeriesDto(id = route.item.seriesId, offline = route.item.offline),
|
||||
focusedSeasonId = route.item.seasonId,
|
||||
focusedEpisodeId = route.item.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ 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
|
||||
@@ -52,7 +52,7 @@ import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
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
|
||||
@@ -147,8 +147,8 @@ class TvActivity : ComponentActivity() {
|
||||
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsState()
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -158,7 +158,7 @@ class TvActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.checkForUpdates(showUpToDateMessage = false)
|
||||
updateViewModel.checkForUpdatesOnAppOpen()
|
||||
}
|
||||
|
||||
LaunchedEffect(updateViewModel, context) {
|
||||
@@ -229,7 +229,7 @@ class TvActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LoginScreen()
|
||||
TvLoginScreen()
|
||||
}
|
||||
|
||||
availableUpdate?.let { update ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ 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.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -20,10 +20,10 @@ 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.model.LibraryKind
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import hu.bbara.purefin.navigation.LocalNavigationManager
|
||||
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
|
||||
@@ -35,10 +35,10 @@ 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
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -106,6 +106,9 @@ fun TvAppScreen(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
if (selectedDestination == Route.Home) {
|
||||
viewModel.onRefresh()
|
||||
}
|
||||
if (selectedDestination != destination) {
|
||||
backStack.clear()
|
||||
backStack.add(Route.Home)
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
@@ -37,15 +37,11 @@ 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 seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
|
||||
val selectedEpisode = episode.value
|
||||
|
||||
if (selectedEpisode == null) {
|
||||
|
||||
@@ -30,12 +30,14 @@ 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.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
|
||||
@@ -52,11 +54,14 @@ fun TvNavigationDrawer(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
ProvideTvDrawerTheme {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
||||
NavigationDrawer(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
drawerContent = { drawerValue ->
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
TvNavigationDrawerRail(
|
||||
drawerValue = drawerValue,
|
||||
drawerState = drawerState,
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected
|
||||
@@ -135,12 +140,12 @@ fun TvDrawerHeader(
|
||||
|
||||
@Composable
|
||||
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||
drawerValue: DrawerValue,
|
||||
drawerState: DrawerState,
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: Route,
|
||||
onDestinationSelected: (Route) -> Unit,
|
||||
) {
|
||||
val expanded = drawerValue == DrawerValue.Open
|
||||
val expanded = drawerState.currentValue == DrawerValue.Open
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var hasDrawerFocus by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -164,7 +169,12 @@ private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = { onDestinationSelected(destination.destination) },
|
||||
onClick = {
|
||||
onDestinationSelected(destination.destination)
|
||||
if (isSelected) {
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected }
|
||||
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
@@ -35,7 +35,7 @@ fun TvLibraryScreen(
|
||||
viewModel.selectLibrary(libraryId = library.id)
|
||||
}
|
||||
|
||||
val libraryItems = viewModel.contents.collectAsState()
|
||||
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -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.core.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
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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
|
||||
@@ -28,11 +28,11 @@ fun TvMovieScreen(
|
||||
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(
|
||||
|
||||
@@ -12,11 +12,15 @@ 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
|
||||
@@ -25,8 +29,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -47,13 +51,18 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
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.TvPlayerTimeRow
|
||||
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.TvTrackPanelType
|
||||
@@ -76,13 +85,15 @@ fun TvPlayerScreen(
|
||||
viewModel.loadMedia(mediaId)
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
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) }
|
||||
var hiddenSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
var hiddenSeekRequestId by remember { mutableStateOf(0) }
|
||||
val controlsAutoHideBlocked = isPlaylistExpanded || trackPanelType != null
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -93,6 +104,11 @@ fun TvPlayerScreen(
|
||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
val activity = context as? Activity
|
||||
if (uiState.isPlaying) {
|
||||
@@ -155,6 +171,9 @@ fun TvPlayerScreen(
|
||||
showTvControls()
|
||||
}
|
||||
val seekByWithoutShowingControls: (Long) -> Unit = { deltaMs ->
|
||||
val basePositionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs
|
||||
hiddenSeekPreviewPositionMs = (basePositionMs + deltaMs).coerceSeekPosition(uiState.durationMs)
|
||||
hiddenSeekRequestId += 1
|
||||
viewModel.seekBy(deltaMs)
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
@@ -214,9 +233,16 @@ fun TvPlayerScreen(
|
||||
stopFeedbackVisible = false
|
||||
}
|
||||
|
||||
LaunchedEffect(hiddenSeekRequestId) {
|
||||
if (hiddenSeekRequestId == 0) return@LaunchedEffect
|
||||
delay(TV_HIDDEN_STOP_FEEDBACK_MS)
|
||||
hiddenSeekPreviewPositionMs = null
|
||||
}
|
||||
|
||||
LaunchedEffect(controlsVisible, isPlaylistExpanded, trackPanelType, uiState.isEnded, uiState.error) {
|
||||
if (controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null) {
|
||||
stopFeedbackVisible = false
|
||||
hiddenSeekPreviewPositionMs = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +349,10 @@ fun TvPlayerScreen(
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 24.dp)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = if (hiddenSeekPreviewPositionMs != null) 104.dp else 24.dp
|
||||
)
|
||||
) {
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
@@ -335,6 +364,23 @@ fun TvPlayerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && hiddenSeekPreviewPositionMs != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 32.dp, vertical = 28.dp)
|
||||
) {
|
||||
HiddenTvSeekTimeline(
|
||||
positionMs = hiddenSeekPreviewPositionMs ?: uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads
|
||||
)
|
||||
}
|
||||
|
||||
TvPlayerLoadingErrorEndCard(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
uiState = uiState,
|
||||
@@ -377,6 +423,53 @@ fun TvPlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenTvSeekTimeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
TvPlayerTimeRow(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = positionMs,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp),
|
||||
isFocused = false,
|
||||
thumbRadius = 7.dp,
|
||||
focusedThumbRadius = 9.dp,
|
||||
focusedThumbHaloRadiusDelta = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.coerceSeekPosition(durationMs: Long): Long =
|
||||
if (durationMs > 0L) {
|
||||
coerceIn(0L, durationMs)
|
||||
} else {
|
||||
coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: androidx.compose.ui.input.key.KeyEvent,
|
||||
isPlaying: Boolean,
|
||||
|
||||
@@ -54,6 +54,18 @@ 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
|
||||
|
||||
@@ -69,10 +81,7 @@ internal fun TvIconButton(
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.background(
|
||||
if (isFocused) scheme.primary.copy(alpha = 0.5f)
|
||||
else scheme.background.copy(alpha = 0.65f)
|
||||
)
|
||||
.background(backgroundColor)
|
||||
.focusProperties { canFocus = enabled }
|
||||
.semantics {
|
||||
if (!enabled) {
|
||||
@@ -86,7 +95,7 @@ internal fun TvIconButton(
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground,
|
||||
tint = contentColor,
|
||||
modifier = buttonModifier
|
||||
.size(size.dp)
|
||||
.padding(12.dp)
|
||||
@@ -104,12 +113,12 @@ internal fun TvIconButton(
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(iconSize)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = scheme.onBackground,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
@@ -187,7 +187,6 @@ private fun TvPlayerBottomSection(
|
||||
subtitlesButtonEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val playlistFocusRequester = remember { FocusRequester() }
|
||||
var wasPlaylistExpanded by remember { mutableStateOf(isPlaylistExpanded) }
|
||||
|
||||
@@ -210,24 +209,11 @@ private fun TvPlayerBottomSection(
|
||||
.testTag(TvPlayerPlaylistStateTag)
|
||||
.semantics { stateDescription = playlistExpandState }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(uiState.positionMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
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,
|
||||
@@ -355,6 +341,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ 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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -37,21 +37,24 @@ import java.util.UUID
|
||||
@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()) {
|
||||
TvSeriesScreenContent(
|
||||
series = seriesData,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
||||
focusedSeasonId = focusedSeasonId,
|
||||
focusedEpisodeId = focusedEpisodeId,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -64,27 +67,32 @@ fun TvSeriesScreen(
|
||||
internal fun TvSeriesScreenContent(
|
||||
series: Series,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
|
||||
focusedSeasonId: UUID? = null,
|
||||
focusedEpisodeId: UUID? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||
val focusedEpisode = remember(series.id, focusedEpisodeId) {
|
||||
focusedEpisodeId?.let { id ->
|
||||
series.seasons
|
||||
.flatMap { it.episodes }
|
||||
.firstOrNull { it.id == id }
|
||||
}
|
||||
val defaultSeason = series.defaultSeason(focusedSeasonId)
|
||||
var selectedSeasonId by remember(series.id, focusedSeasonId) {
|
||||
mutableStateOf(defaultSeason.id)
|
||||
}
|
||||
val initialEpisode = focusedEpisode ?: nextUpEpisode
|
||||
var selectedSeason by remember(series.id, initialEpisode?.seasonId) {
|
||||
mutableStateOf(series.defaultSeason(initialEpisode))
|
||||
}
|
||||
val firstContentFocusRequester = remember { FocusRequester() }
|
||||
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
|
||||
val initialFocusSeasonId = remember(series.id, focusedSeasonId) { defaultSeason.id }
|
||||
val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason
|
||||
val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId)
|
||||
val seasonTabFocusRequester = remember { FocusRequester() }
|
||||
val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
|
||||
initialFocusSeason.episodes.isEmpty() &&
|
||||
initialFocusSeason.episodeCount > 0
|
||||
|
||||
LaunchedEffect(series.id, initialEpisode?.id) {
|
||||
if (initialEpisode != null) return@LaunchedEffect
|
||||
LaunchedEffect(series.id, selectedSeason.id) {
|
||||
onLoadSeasonEpisodes(series.id, selectedSeason.id)
|
||||
}
|
||||
|
||||
LaunchedEffect(series.id, initialFocusedEpisodeId, waitingForInitialEpisodes) {
|
||||
if (initialFocusedEpisodeId != null || waitingForInitialEpisodes) return@LaunchedEffect
|
||||
withFrameNanos { }
|
||||
firstContentFocusRequester.requestFocus()
|
||||
seasonTabFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
TvMediaDetailScaffold(
|
||||
@@ -109,16 +117,16 @@ internal fun TvSeriesScreenContent(
|
||||
TvSeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
selectedItemFocusRequester = seasonTabFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeason = it },
|
||||
onSelect = { selectedSeasonId = it.id },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TvEpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
focusedEpisodeId = initialEpisode?.id,
|
||||
focusedEpisodeId = initialFocusedEpisodeId,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
@@ -126,19 +134,19 @@ internal fun TvSeriesScreenContent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Series.defaultSeason(nextUpEpisode: Episode?): Season {
|
||||
if (nextUpEpisode != null) {
|
||||
seasons.firstOrNull { it.id == nextUpEpisode.seasonId }?.let { return it }
|
||||
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season {
|
||||
if (focusedSeasonId != null) {
|
||||
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it }
|
||||
}
|
||||
|
||||
for (season in seasons) {
|
||||
if (season.episodes.any { !it.watched }) {
|
||||
return season
|
||||
}
|
||||
}
|
||||
return seasons.first()
|
||||
return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.first()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ 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.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -81,7 +82,7 @@ internal fun TvSeasonTabs(
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
selectedItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
@@ -90,6 +91,7 @@ internal fun TvSeasonTabs(
|
||||
TabRow(
|
||||
selectedTabIndex = selectedSeasonIndex,
|
||||
modifier = modifier
|
||||
.focusRestorer()
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.Start),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
@@ -102,8 +104,8 @@ internal fun TvSeasonTabs(
|
||||
colors = TabDefaults.underlinedIndicatorTabColors(),
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
|
||||
Modifier.focusRequester(selectedItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -143,6 +145,7 @@ internal fun TvEpisodeCarousel(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val focusedEpisodeFocusRequester = remember { FocusRequester() }
|
||||
var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) }
|
||||
|
||||
LaunchedEffect(episodes, focusedEpisodeId) {
|
||||
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||
@@ -157,9 +160,10 @@ internal fun TvEpisodeCarousel(
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
if (focusedEpisodeIndex >= 0) {
|
||||
if (focusedEpisodeIndex >= 0 && requestedFocusEpisodeId != focusedEpisodeId) {
|
||||
withFrameNanos { }
|
||||
focusedEpisodeFocusRequester.requestFocus()
|
||||
requestedFocusEpisodeId = focusedEpisodeId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -11,6 +13,8 @@ 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
|
||||
@@ -21,18 +25,26 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.SettingsOptions
|
||||
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
|
||||
@@ -41,6 +53,15 @@ fun TvSettingsScreen(
|
||||
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,
|
||||
@@ -63,55 +84,111 @@ fun TvSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Playback",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.numberSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvNumberSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.booleanSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvBooleanSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.stringSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
TvStringSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
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
|
||||
@@ -131,6 +208,97 @@ private fun TvSettingsTopBar(
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
|
||||
@@ -17,18 +17,23 @@ android {
|
||||
applicationId = "hu.bbara.purefin"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 1000009
|
||||
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"
|
||||
)
|
||||
@@ -44,6 +49,19 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
@@ -58,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)
|
||||
|
||||
4
app/src/debug/res/values/colors.xml
Normal file
4
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_foreground">#E53935</color>
|
||||
</resources>
|
||||
@@ -22,7 +22,7 @@
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<meta-data
|
||||
android:name="hu.bbara.purefin.UPDATE_MANIFEST_URL"
|
||||
android:value="http://purefin.t.bbara.hu/app/update.json" />
|
||||
android:value="${updateManifestUrl}" />
|
||||
<activity
|
||||
android:name=".PurefinActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -16,12 +16,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
|
||||
@@ -144,7 +146,7 @@ class PurefinActivity : ComponentActivity() {
|
||||
navigationManager: NavigationManager
|
||||
) {
|
||||
var sessionLoaded by remember { mutableStateOf(false) }
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false)
|
||||
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
userSessionRepository.isLoggedIn.collect {
|
||||
@@ -192,7 +194,10 @@ class PurefinActivity : ComponentActivity() {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { navigationManager.pop() },
|
||||
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundDark)
|
||||
.semantics { testTagsAsResourceId = true },
|
||||
transitionSpec = {
|
||||
fadeIn(
|
||||
animationSpec = tween(
|
||||
@@ -227,7 +232,9 @@ class PurefinActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LoginScreen()
|
||||
LoginScreen(
|
||||
modifier = Modifier.semantics { testTagsAsResourceId = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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.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.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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
|
||||
@Composable
|
||||
fun MediaDetailScaffold(
|
||||
imageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
imageHeight: Dp = 320.dp,
|
||||
contentOverlap: Dp = 24.dp,
|
||||
contentSpacing: Dp = 16.dp,
|
||||
topBar: @Composable () -> Unit = {},
|
||||
heroContent: @Composable ColumnScope.() -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.fillMaxWidth()
|
||||
.height(imageHeight)
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
topBar()
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = imageHeight - contentOverlap),
|
||||
verticalArrangement = Arrangement.spacedBy(contentSpacing)
|
||||
) {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
heroContent()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -38,14 +38,15 @@ fun AppScreen(
|
||||
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 isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsState()
|
||||
val availableUpdate by updateViewModel.availableUpdate.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 isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
|
||||
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -54,20 +55,31 @@ fun AppScreen(
|
||||
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
|
||||
val selectedTab = currentRoute.toTabIndex()
|
||||
|
||||
LaunchedEffect(isOnline, currentRoute, backStack) {
|
||||
if (!isOnline && currentRoute != AppTabRoute.Downloads) {
|
||||
backStack.clear()
|
||||
backStack.add(AppTabRoute.Downloads)
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.onResumed()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.checkForUpdatesOnAppOpen()
|
||||
}
|
||||
|
||||
LaunchedEffect(updateViewModel) {
|
||||
updateViewModel.snackbarMessages.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
val onTabSelected = remember(backStack) {
|
||||
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)
|
||||
}
|
||||
@@ -100,6 +112,7 @@ fun AppScreen(
|
||||
onSearchClick = viewModel::openSearch,
|
||||
snackbarHostState = snackbarHostState,
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -110,6 +123,7 @@ fun AppScreen(
|
||||
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) },
|
||||
onSearchClick = viewModel::openSearch,
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -117,6 +131,7 @@ fun AppScreen(
|
||||
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
|
||||
DownloadsScreen(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import hu.bbara.purefin.ui.screen.AppBottomBar
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -22,6 +23,7 @@ fun DownloadsScreen(
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -33,9 +34,10 @@ import hu.bbara.purefin.core.feature.downloads.ActiveDownloadItem
|
||||
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-"
|
||||
|
||||
@@ -17,9 +17,10 @@ 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
|
||||
@@ -31,14 +32,16 @@ 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 +65,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 +85,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()) {
|
||||
@@ -101,7 +107,13 @@ fun DownloadsContent(
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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-"
|
||||
|
||||
@@ -4,51 +4,33 @@ 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 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.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.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.ui.common.media.homeMediaSharedBoundsDestination
|
||||
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
|
||||
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.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
|
||||
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
|
||||
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.core.image.ArtworkKind
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -61,15 +43,11 @@ 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 episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
@@ -103,7 +81,6 @@ fun EpisodeScreen(
|
||||
},
|
||||
downloadState = downloadState.value,
|
||||
onBack = viewModel::onBack,
|
||||
onSeriesClick = viewModel::onSeriesClick,
|
||||
onDownloadClick = onDownloadClick,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -115,105 +92,51 @@ private fun EpisodeScreenInternal(
|
||||
topBarShortcut: EpisodeTopBarShortcut?,
|
||||
downloadState: DownloadState,
|
||||
onBack: () -> Unit,
|
||||
onSeriesClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||
|
||||
Scaffold(
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
EpisodeTopBar(
|
||||
shortcut = topBarShortcut,
|
||||
onBack = onBack,
|
||||
onSeriesClick = onSeriesClick,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
EpisodeHeroSection(episode = episode)
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
EpisodeDetails(
|
||||
episode = episode,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
)
|
||||
}
|
||||
EpisodeTopBar(
|
||||
shortcut = topBarShortcut,
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
heroContent = {
|
||||
EpisodeHeroContent(episode = episode)
|
||||
}
|
||||
) {
|
||||
EpisodeDetails(
|
||||
episode = episode,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeHeroSection(
|
||||
private fun EpisodeHeroContent(
|
||||
episode: Episode,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||
val sectionHeight = screenHeight * 0.4f
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
EpisodeMetaChips(episode = episode)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -233,7 +156,6 @@ private fun EpisodeScreenPreview() {
|
||||
topBarShortcut = EpisodeTopBarShortcut.Series(onClick = {}),
|
||||
downloadState = DownloadState.Downloading(progressPercent = 0.42f),
|
||||
onBack = {},
|
||||
onSeriesClick = {},
|
||||
onDownloadClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
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
|
||||
@@ -21,15 +14,17 @@ 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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
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
|
||||
@@ -44,6 +39,7 @@ 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
|
||||
@@ -58,55 +54,50 @@ internal sealed interface EpisodeTopBarShortcut {
|
||||
internal fun EpisodeTopBar(
|
||||
shortcut: EpisodeTopBarShortcut?,
|
||||
onBack: () -> Unit,
|
||||
onSeriesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -114,7 +105,6 @@ internal fun EpisodeDetails(
|
||||
episode: Episode,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -127,64 +117,63 @@ internal fun EpisodeDetails(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
MediaSynopsis(
|
||||
synopsis = episode.synopsis
|
||||
MediaSynopsis(
|
||||
synopsis = episode.synopsis
|
||||
)
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(EpisodePlayButtonTag)
|
||||
)
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = 2.dp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
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)
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
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,
|
||||
modifier = Modifier.testTag(EpisodeDownloadButtonTag),
|
||||
onClick = onDownloadClick
|
||||
)
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = 2.dp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Row() {
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
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"
|
||||
}
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
//TODO fix it
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
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
|
||||
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"
|
||||
|
||||
@@ -35,6 +35,7 @@ fun HomeScreen(
|
||||
onSearchClick: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -57,6 +58,7 @@ fun HomeScreen(
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -35,11 +36,12 @@ import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
fun DefaultTopBar(
|
||||
leftActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
rightActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
withIcon: Boolean = true
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
color = scheme.background,
|
||||
color = Color.Transparent,
|
||||
contentColor = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@@ -54,25 +56,29 @@ fun DefaultTopBar(
|
||||
if (leftActions != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = leftActions
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = when {
|
||||
leftActions == null && rightActions != null -> Arrangement.Start
|
||||
leftActions != null && rightActions == null -> Arrangement.Center
|
||||
else -> Arrangement.Center
|
||||
if (withIcon) {
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = when {
|
||||
leftActions == null && rightActions != null -> Arrangement.Start
|
||||
leftActions != null && rightActions == null -> Arrangement.Center
|
||||
else -> Arrangement.Center
|
||||
}
|
||||
) {
|
||||
PurefinLogo(
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
) {
|
||||
PurefinLogo(
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
if (rightActions != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = rightActions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
@@ -80,7 +81,9 @@ fun HomeContent(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = modifier.fillMaxSize()
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(HomeContentTag)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -89,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)
|
||||
) {
|
||||
@@ -97,7 +102,8 @@ fun HomeContent(
|
||||
item(key = "featured") {
|
||||
SuggestionsSection(
|
||||
items = suggestions,
|
||||
onItemOpen = { item -> onMediaSelected(item) }
|
||||
onItemOpen = { item -> onMediaSelected(item) },
|
||||
modifier = Modifier.testTag(HomeFeaturedSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +113,8 @@ fun HomeContent(
|
||||
ContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag(HomeContinueWatchingSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +123,8 @@ fun HomeContent(
|
||||
item(key = "next-up") {
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag(HomeNextUpSectionTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +137,8 @@ fun HomeContent(
|
||||
library = library,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag("$HomeLibrarySectionTagPrefix${library.id}")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,7 +146,8 @@ fun HomeContent(
|
||||
item(key = "empty-state") {
|
||||
HomeEmptyState(
|
||||
onRefresh = onRefresh,
|
||||
onBrowseLibrariesClick = onBrowseLibrariesClick
|
||||
onBrowseLibrariesClick = onBrowseLibrariesClick,
|
||||
modifier = Modifier.testTag(HomeEmptyStateTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,3 +155,11 @@ fun HomeContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -12,8 +10,6 @@ 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.unit.dp
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
@@ -29,7 +25,6 @@ fun HomeTopBar(
|
||||
DefaultTopBar(
|
||||
rightActions = {
|
||||
DefaultTopBarSearchButton(onClick = onSearchClick)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
DefaultTopBarIconButton(
|
||||
imageVector = Icons.Outlined.Person,
|
||||
contentDescription = "Profile",
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.common.header.SectionHeader
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||
@@ -41,7 +42,9 @@ fun ContinueWatchingSection(
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(HomeContinueWatchingRowTag),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
@@ -49,9 +52,13 @@ fun ContinueWatchingSection(
|
||||
item = item,
|
||||
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
|
||||
onMediaSelected = onMediaSelected,
|
||||
onMarkAsWatched = onMarkAsWatched
|
||||
onMarkAsWatched = onMarkAsWatched,
|
||||
modifier = Modifier.testTag("$HomeContinueWatchingItemTagPrefix$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val HomeContinueWatchingRowTag = "home-continue-watching-row"
|
||||
internal const val HomeContinueWatchingItemTagPrefix = "home-continue-watching-item-"
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.common.media.homeMediaSharedBoundsKey
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
@@ -48,13 +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 = item,
|
||||
sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id),
|
||||
onClick = { onItemOpen(item) }
|
||||
onClick = { onItemOpen(item) },
|
||||
modifier = Modifier.testTag("$HomeFeaturedItemTagPrefix$page")
|
||||
)
|
||||
}
|
||||
if (items.size > 1) {
|
||||
@@ -86,3 +90,6 @@ fun SuggestionsSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val HomeFeaturedPagerTag = "home-featured-pager"
|
||||
internal const val HomeFeaturedItemTagPrefix = "home-featured-item-"
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.common.header.SectionHeader
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||
@@ -47,7 +48,9 @@ fun LibraryPosterSection(
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("$HomeLibraryRowTagPrefix${library.id}"),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
@@ -57,9 +60,13 @@ fun LibraryPosterSection(
|
||||
origin = "library-${library.id}-$index",
|
||||
mediaId = item.id
|
||||
),
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag("$HomeLibraryItemTagPrefix${library.id}-$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val HomeLibraryRowTagPrefix = "home-library-row-"
|
||||
internal const val HomeLibraryItemTagPrefix = "home-library-item-"
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.common.header.SectionHeader
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
|
||||
@@ -40,16 +41,22 @@ fun NextUpSection(
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(HomeNextUpRowTag),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||
NextUpCard(
|
||||
uiModel = item,
|
||||
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
|
||||
onMediaSelected = onMediaSelected
|
||||
onMediaSelected = onMediaSelected,
|
||||
modifier = Modifier.testTag("$HomeNextUpItemTagPrefix$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val HomeNextUpRowTag = "home-next-up-row"
|
||||
internal const val HomeNextUpItemTagPrefix = "home-next-up-item-"
|
||||
|
||||
@@ -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
|
||||
@@ -55,7 +55,7 @@ fun SearchOverlay(
|
||||
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(
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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
|
||||
@@ -147,6 +148,7 @@ private fun SearchScreenContent(
|
||||
) },
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(SearchScreenTag)
|
||||
.background(scheme.background)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
@@ -261,7 +263,10 @@ private fun SearchField(
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
IconButton(
|
||||
onClick = { onQueryChange("") },
|
||||
modifier = Modifier.testTag(SearchClearButtonTag)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Clear search",
|
||||
@@ -286,6 +291,7 @@ private fun SearchField(
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(SearchQueryFieldTag)
|
||||
.height(68.dp)
|
||||
)
|
||||
}
|
||||
@@ -298,18 +304,23 @@ private fun SearchResultsGrid(
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(SearchResultsGridTag)
|
||||
) {
|
||||
results.chunked(2).forEach { rowItems ->
|
||||
results.chunked(2).forEachIndexed { rowIndex, rowItems ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
rowItems.forEach { item ->
|
||||
rowItems.forEachIndexed { columnIndex, item ->
|
||||
val itemIndex = rowIndex * 2 + columnIndex
|
||||
SearchResultCard(
|
||||
item = item,
|
||||
onClick = { onResultClick(item) },
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag("$SearchResultItemTagPrefix$itemIndex")
|
||||
)
|
||||
}
|
||||
if (rowItems.size == 1) {
|
||||
@@ -367,6 +378,7 @@ private fun GenreSelector(
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { showGenres = true },
|
||||
modifier = Modifier.testTag(SearchGenreSelectorTag),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tune,
|
||||
@@ -388,11 +400,12 @@ private fun GenreSelector(
|
||||
)
|
||||
}
|
||||
)
|
||||
visibleGenres.forEach { genre ->
|
||||
visibleGenres.forEachIndexed { index, genre ->
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
selected = genre.name in selectedGenreNames,
|
||||
onClick = { onGenreSelected(genre) }
|
||||
onClick = { onGenreSelected(genre) },
|
||||
modifier = Modifier.testTag("$SearchGenreChipTagPrefix$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +413,7 @@ private fun GenreSelector(
|
||||
if (showGenres) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showGenres = false },
|
||||
modifier = Modifier.testTag(SearchGenreDialogTag),
|
||||
title = {
|
||||
Text(text = "Select genres")
|
||||
},
|
||||
@@ -412,11 +426,12 @@ private fun GenreSelector(
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
genres.forEach { genre ->
|
||||
genres.forEachIndexed { index, genre ->
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
selected = genre.name in selectedGenreNames,
|
||||
onClick = { onGenreSelected(genre) }
|
||||
onClick = { onGenreSelected(genre) },
|
||||
modifier = Modifier.testTag("$SearchGenreDialogChipTagPrefix$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -514,3 +529,13 @@ private val previewSearchResults = listOf(
|
||||
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-"
|
||||
|
||||
@@ -17,6 +17,7 @@ fun LibrariesScreen(
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
selectedTab: Int,
|
||||
isOnline: Boolean,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -32,6 +33,7 @@ fun LibrariesScreen(
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
selectedTab = selectedTab,
|
||||
isOnline = isOnline,
|
||||
onTabSelected = onTabSelected
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.core.model.LibraryUiModel
|
||||
|
||||
@@ -17,7 +18,7 @@ fun LibrariesContent(
|
||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
BoxWithConstraints(modifier = modifier.testTag(LibrariesOverviewGridTag)) {
|
||||
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
|
||||
|
||||
LazyVerticalGrid(
|
||||
@@ -29,9 +30,13 @@ fun LibrariesContent(
|
||||
items(items, key = { it.id }) { item ->
|
||||
LibraryListItem(
|
||||
uiModel = item,
|
||||
onClick = { onLibrarySelected(item) }
|
||||
onClick = { onLibrarySelected(item) },
|
||||
modifier = Modifier.testTag("$LibrariesOverviewItemTagPrefix${item.id}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val LibrariesOverviewGridTag = "libraries-overview-grid"
|
||||
internal const val LibrariesOverviewItemTagPrefix = "libraries-overview-item-"
|
||||
|
||||
@@ -14,9 +14,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.core.feature.browse.library.LibraryViewModel
|
||||
@@ -39,10 +40,10 @@ fun LibraryScreen(
|
||||
viewModel.selectLibrary(libraryId = library.id)
|
||||
}
|
||||
|
||||
val libraryItems = viewModel.contents.collectAsState()
|
||||
val libraryItems = viewModel.contents.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
modifier = modifier.testTag(LibraryScreenTag),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
@@ -68,7 +69,11 @@ internal fun LibraryPosterGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LibraryViewModel = hiltViewModel()
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.testTag(LibraryPosterGridTag)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp
|
||||
|
||||
LazyVerticalGrid(
|
||||
@@ -88,7 +93,8 @@ internal fun LibraryPosterGrid(
|
||||
is SeriesUiModel -> viewModel.onSeriesSelected(item.id)
|
||||
is EpisodeUiModel -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("$LibraryPosterItemTagPrefix${item.id}")
|
||||
) {
|
||||
when (item) {
|
||||
is MovieUiModel, is EpisodeUiModel -> {
|
||||
@@ -107,3 +113,7 @@ internal fun LibraryPosterGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val LibraryScreenTag = "library-screen"
|
||||
internal const val LibraryPosterGridTag = "library-poster-grid"
|
||||
internal const val LibraryPosterItemTagPrefix = "library-poster-item-"
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package hu.bbara.purefin.ui.screen.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.common.button.PurefinTextButton
|
||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField
|
||||
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Composable
|
||||
fun LoginContent(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
if (state.isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(LoginContentTag)
|
||||
.background(scheme.background)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PurefinLogo(
|
||||
color = scheme.primary,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Purefin",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Personal media, your way",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
state.errorMessage?.let { errorMessage ->
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = scheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginErrorMessageTag)
|
||||
.background(
|
||||
scheme.errorContainer,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
when (state.phase) {
|
||||
LoginContentPhase.ServerSearch -> ServerSearchContent(state, callbacks)
|
||||
LoginContentPhase.Login -> LoginPhaseContent(state, callbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerSearchContent(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Text(
|
||||
text = "Find your server",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Search by address or choose a nearby Jellyfin server.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 2.dp, bottom = 16.dp).fillMaxWidth()
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.testTag(LoginServerUrlFieldTag)) {
|
||||
PurefinComplexTextField(
|
||||
label = "Server URL",
|
||||
value = state.serverUrl,
|
||||
onValueChange = callbacks.onServerUrlChange,
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text(if (state.isSearching) "Searching..." else "Find server") },
|
||||
onClick = callbacks.onFindServer,
|
||||
enabled = !state.isSearching,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginFindServerButtonTag)
|
||||
.height(48.dp)
|
||||
)
|
||||
|
||||
if (state.discoveredServers.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(20.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.forEachIndexed { index, server ->
|
||||
TextButton(
|
||||
onClick = { callbacks.onDiscoveredServerClick(server) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("$LoginDiscoveredServerTagPrefix$index")
|
||||
) {
|
||||
Text(
|
||||
text = server.name?.let { "$it\n${server.address}" } ?: server.address,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginPhaseContent(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
|
||||
|
||||
Text(
|
||||
text = "Log in",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = selectedServer,
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth()
|
||||
)
|
||||
TextButton(
|
||||
onClick = callbacks.onChangeServer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginChangeServerButtonTag)
|
||||
) {
|
||||
Text("Change server")
|
||||
}
|
||||
|
||||
if (state.quickConnectAvailable) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
state.quickConnectCode?.let { code ->
|
||||
Text(
|
||||
text = code,
|
||||
color = scheme.primary,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginQuickConnectCodeTag)
|
||||
)
|
||||
Text(
|
||||
text = "Approve this code in another Jellyfin client.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
TextButton(
|
||||
onClick = callbacks.onCancelQuickConnect,
|
||||
modifier = Modifier.testTag(LoginQuickConnectCancelButtonTag)
|
||||
) {
|
||||
Text("Cancel Quick Connect")
|
||||
}
|
||||
} ?: PurefinTextButton(
|
||||
content = { Text("Quick Connect") },
|
||||
onClick = callbacks.onQuickConnect,
|
||||
enabled = !state.isQuickConnecting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginQuickConnectButtonTag)
|
||||
.height(48.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Quick Connect is not enabled on this server.",
|
||||
color = scheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "Manual login",
|
||||
color = scheme.onBackground,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Column(modifier = Modifier.testTag(LoginUsernameFieldTag)) {
|
||||
PurefinComplexTextField(
|
||||
label = "Username",
|
||||
value = state.username,
|
||||
onValueChange = callbacks.onUsernameChange,
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Column(modifier = Modifier.testTag(LoginPasswordFieldTag)) {
|
||||
PurefinPasswordField(
|
||||
label = "Password",
|
||||
value = state.password,
|
||||
onValueChange = callbacks.onPasswordChange,
|
||||
placeholder = "Enter your password",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text("Connect") },
|
||||
onClick = callbacks.onConnect,
|
||||
enabled = !state.isQuickConnecting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(LoginConnectButtonTag)
|
||||
.height(48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
internal const val LoginContentTag = "login-content"
|
||||
internal const val LoginFindServerButtonTag = "login-find-server-button"
|
||||
internal const val LoginServerUrlFieldTag = "login-server-url-field"
|
||||
internal const val LoginDiscoveredServerTagPrefix = "login-discovered-server-"
|
||||
internal const val LoginChangeServerButtonTag = "login-change-server-button"
|
||||
internal const val LoginUsernameFieldTag = "login-username-field"
|
||||
internal const val LoginPasswordFieldTag = "login-password-field"
|
||||
internal const val LoginQuickConnectButtonTag = "login-quick-connect-button"
|
||||
internal const val LoginQuickConnectCodeTag = "login-quick-connect-code"
|
||||
internal const val LoginQuickConnectCancelButtonTag = "login-quick-connect-cancel-button"
|
||||
internal const val LoginConnectButtonTag = "login-connect-button"
|
||||
internal const val LoginErrorMessageTag = "login-error-message"
|
||||
@@ -1,14 +1,13 @@
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.core.data.JellyfinServerCandidate
|
||||
import hu.bbara.purefin.core.feature.login.LoginViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -17,52 +16,83 @@ 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 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(serverUrl, username, password, errorMessage) {
|
||||
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.clearError()
|
||||
viewModel.setUrl(it)
|
||||
onServerUrlChange = viewModel::setUrl,
|
||||
onFindServer = viewModel::findServer,
|
||||
onDiscoveredServerClick = {
|
||||
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
|
||||
},
|
||||
onChangeServer = viewModel::changeServer,
|
||||
onUsernameChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
onPasswordChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
onConnect = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
viewModel.login()
|
||||
}
|
||||
}
|
||||
},
|
||||
onQuickConnect = viewModel::startQuickConnect,
|
||||
onCancelQuickConnect = viewModel::cancelQuickConnect
|
||||
)
|
||||
}
|
||||
|
||||
LoginContent(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
isLoggingIn = isLoggingIn,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,43 +4,25 @@ 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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.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.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.model.CastMember
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaHero
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination
|
||||
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
|
||||
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
@@ -49,14 +31,16 @@ import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun MovieScreen(
|
||||
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 downloadState = viewModel.downloadState.collectAsState()
|
||||
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
|
||||
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
@@ -96,93 +80,43 @@ private fun MovieScreenInternal(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||
|
||||
Scaffold(
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
MovieTopBar(
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
MediaHeroSection(movie = movie)
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
MovieDetails(
|
||||
movie = movie,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
)
|
||||
}
|
||||
MovieTopBar(onBack = onBack)
|
||||
},
|
||||
heroContent = {
|
||||
MovieHeroContent(movie = movie)
|
||||
}
|
||||
) {
|
||||
MovieDetails(
|
||||
movie = movie,
|
||||
downloadState = downloadState,
|
||||
onDownloadClick = onDownloadClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaHeroSection(
|
||||
private fun MovieHeroContent(
|
||||
movie: Movie,
|
||||
modifier: Modifier = Modifier) {
|
||||
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||
val sectionHeight = screenHeight * 0.4f
|
||||
|
||||
Box (
|
||||
modifier = modifier
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.height(sectionHeight)
|
||||
) {
|
||||
MediaHero(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
backgroundColor = MaterialTheme.colorScheme.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 = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
package hu.bbara.purefin.ui.screen.movie.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Cast
|
||||
@@ -23,9 +19,9 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.sp
|
||||
@@ -39,30 +35,29 @@ 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
|
||||
|
||||
@Composable
|
||||
internal fun MovieTopBar(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
)
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -71,7 +66,6 @@ internal fun MovieDetails(
|
||||
movie: Movie,
|
||||
downloadState: DownloadState,
|
||||
onDownloadClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -84,63 +78,62 @@ internal fun MovieDetails(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
MediaSynopsis(
|
||||
synopsis = movie.synopsis
|
||||
MediaSynopsis(
|
||||
synopsis = movie.synopsis
|
||||
)
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(MoviePlayButtonTag)
|
||||
)
|
||||
VerticalDivider(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
thickness = 4.dp,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
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,
|
||||
modifier = Modifier.testTag(MovieDownloadButtonTag),
|
||||
onClick = onDownloadClick
|
||||
)
|
||||
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.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
foregroundColor = MaterialTheme.colorScheme.onSurface,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
//TODO fix
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
//TODO fix
|
||||
// MediaCastRow(
|
||||
// cast = movie.cast,
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||
internal const val MovieDownloadButtonTag = "movie-download-button"
|
||||
|
||||
@@ -11,8 +11,10 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -21,9 +23,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -34,10 +37,15 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
@@ -47,12 +55,17 @@ import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||
private const val WIDE_VIDEO_ASPECT_RATIO = 16f / 9f
|
||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||
private const val AUTO_BRIGHTNESS_SENTINEL = -0.1f
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
@@ -60,8 +73,8 @@ fun PlayerScreen(
|
||||
viewModel: PlayerViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
@@ -71,8 +84,26 @@ fun PlayerScreen(
|
||||
var brightness by remember { mutableStateOf(readCurrentBrightness(activity)) }
|
||||
var showQueuePanel by remember { mutableStateOf(false) }
|
||||
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
|
||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
var videoAspectRatio by remember { mutableStateOf(calculateVideoAspectRatio(viewModel.player.videoSize)) }
|
||||
val overlayController = rememberPersistentOverlayController()
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
viewModel.pausePlayback()
|
||||
}
|
||||
|
||||
DisposableEffect(viewModel.player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
videoAspectRatio = calculateVideoAspectRatio(videoSize)
|
||||
}
|
||||
}
|
||||
viewModel.player.addListener(listener)
|
||||
onDispose {
|
||||
viewModel.player.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying) {
|
||||
showQueuePanel = false
|
||||
@@ -80,6 +111,7 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
||||
val playerResizeMode = playerResizeMode(videoAspectRatio)
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
@@ -96,18 +128,18 @@ fun PlayerScreen(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
resizeMode = playerResizeMode
|
||||
player = viewModel.player
|
||||
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
it.player = viewModel.player
|
||||
it.resizeMode = playerResizeMode
|
||||
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
PlayerGesturesLayer(
|
||||
@@ -118,7 +150,7 @@ fun PlayerScreen(
|
||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||
onVerticalDragLeft = { delta ->
|
||||
val diff = (-delta / 800f)
|
||||
brightness = (brightness + diff).coerceIn(0f, 1f)
|
||||
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_SENTINEL, 1f)
|
||||
applyBrightness(activity, brightness)
|
||||
},
|
||||
onVerticalDragRight = { delta ->
|
||||
@@ -130,8 +162,15 @@ fun PlayerScreen(
|
||||
0
|
||||
)
|
||||
},
|
||||
onHorizontalDragPreview = {
|
||||
horizontalSeekFeedback = it
|
||||
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
|
||||
horizontalSeekFeedback = deltaMs
|
||||
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
|
||||
if (uiState.durationMs > 0L) {
|
||||
it.coerceIn(0L, uiState.durationMs)
|
||||
} else {
|
||||
it.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
},
|
||||
onHorizontalDragSeekTo = {
|
||||
viewModel.seekTo(it)
|
||||
@@ -150,6 +189,25 @@ fun PlayerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
EmptyValueTimedVisibility(
|
||||
value = horizontalSeekPreviewPositionMs,
|
||||
hideAfterMillis = 1_000
|
||||
) { previewPositionMs ->
|
||||
if (!playerControlsVisible) {
|
||||
HiddenSeekTimeline(
|
||||
positionMs = previewPositionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
bufferedMs = uiState.bufferedMs,
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
isLive = uiState.isLive,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 24.dp, vertical = 28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ValueChangeTimedVisibility(
|
||||
value = brightness,
|
||||
hideAfterMillis = 800,
|
||||
@@ -212,7 +270,10 @@ fun PlayerScreen(
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 24.dp)
|
||||
.padding(
|
||||
end = 24.dp,
|
||||
bottom = if (horizontalSeekPreviewPositionMs != null) 96.dp else 24.dp
|
||||
)
|
||||
) {
|
||||
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
||||
}
|
||||
@@ -252,6 +313,63 @@ fun PlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun playerResizeMode(videoAspectRatio: Float): Int =
|
||||
if (videoAspectRatio >= WIDE_VIDEO_ASPECT_RATIO) {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
private fun calculateVideoAspectRatio(videoSize: VideoSize): Float {
|
||||
if (videoSize.width <= 0 || videoSize.height <= 0) {
|
||||
return 0f
|
||||
}
|
||||
return videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HiddenSeekTimeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
isLive: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
) {
|
||||
PlayerTimeRow(
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
isLive = isLive,
|
||||
onSeekLiveEdge = null
|
||||
)
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = positionMs,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp),
|
||||
isFocused = false,
|
||||
focusedTrackHeight = 4.dp,
|
||||
focusedThumbRadius = 6.dp,
|
||||
focusedThumbHaloRadiusDelta = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeekAmountIndicator(deltaMs: Long, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -284,12 +402,12 @@ private fun formatSeekDelta(deltaMs: Long): String {
|
||||
|
||||
private fun readCurrentBrightness(activity: Activity?): Float {
|
||||
val current = activity?.window?.attributes?.screenBrightness
|
||||
return if (current != null && current >= 0) current else 0.5f
|
||||
return if (current != null && current >= 0) current else AUTO_BRIGHTNESS_SENTINEL
|
||||
}
|
||||
|
||||
private fun applyBrightness(activity: Activity?, value: Float) {
|
||||
activity ?: return
|
||||
val params = activity.window.attributes
|
||||
params.screenBrightness = value
|
||||
params.screenBrightness = if (value < 0f) -1f else value
|
||||
activity.window.attributes = params
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ fun PlayerAdjustmentIndicator(
|
||||
sliderHeight: Dp = 140.dp,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val percent = (value.coerceIn(0f, 1f) * 100).roundToInt()
|
||||
val isAuto = value < 0f
|
||||
val percent = if (isAuto) 0 else (value * 100).roundToInt()
|
||||
val clamped = value.coerceIn(0f, 1f)
|
||||
|
||||
Box(
|
||||
@@ -68,9 +69,10 @@ fun PlayerAdjustmentIndicator(
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val label = if (isAuto) "Auto" else "$percent%"
|
||||
Text(
|
||||
text = "$percent%",
|
||||
color = scheme.onSurface,
|
||||
text = label,
|
||||
color = if (isAuto) scheme.tertiary else scheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ 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.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
@@ -65,12 +66,12 @@ fun PlayerControlsOverlay(
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var scrubbing by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(PlayerControlsOverlayTag)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -150,6 +151,7 @@ private fun TopBar(
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.PlaylistPlay,
|
||||
contentDescription = "Queue",
|
||||
modifier = Modifier.testTag(PlayerQueueButtonTag),
|
||||
onClick = onOpenQueue
|
||||
)
|
||||
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = onCast)
|
||||
@@ -177,40 +179,14 @@ private fun BottomSection(
|
||||
onQueueSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(uiState.positionMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (uiState.isLive) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.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.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerTimeRow(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
isLive = uiState.isLive,
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
PlayerSeekBar(
|
||||
positionMs = uiState.positionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
@@ -219,7 +195,8 @@ private fun BottomSection(
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onScrub,
|
||||
onScrubStarted = onScrubStart,
|
||||
onScrubFinished = onScrubFinished
|
||||
onScrubFinished = onScrubFinished,
|
||||
modifier = Modifier.testTag(PlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
@@ -236,30 +213,35 @@ private fun BottomSection(
|
||||
icon = Icons.Outlined.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
onClick = onPrevious,
|
||||
modifier = Modifier.testTag(PlayerPreviousButtonTag),
|
||||
size = 64
|
||||
)
|
||||
PurefinIconButton(
|
||||
icon = Icons.Outlined.Replay10,
|
||||
contentDescription = "Seek backward",
|
||||
onClick = onSeekBackward,
|
||||
modifier = Modifier.testTag(PlayerSeekBackwardButtonTag),
|
||||
size = 64
|
||||
)
|
||||
PurefinIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = "Play/Pause",
|
||||
onClick = onPlayPause,
|
||||
modifier = Modifier.testTag(PlayerPlayPauseButtonTag),
|
||||
size = 64
|
||||
)
|
||||
PurefinIconButton(
|
||||
icon = Icons.Outlined.Forward30,
|
||||
contentDescription = "Seek forward",
|
||||
onClick = onSeekForward,
|
||||
modifier = Modifier.testTag(PlayerSeekForwardButtonTag),
|
||||
size = 64
|
||||
)
|
||||
PurefinIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
onClick = onNext,
|
||||
modifier = Modifier.testTag(PlayerNextButtonTag),
|
||||
size = 64
|
||||
)
|
||||
}
|
||||
@@ -290,7 +272,9 @@ private fun BottomSection(
|
||||
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||
SkipSegmentButton(
|
||||
onClick = onSkipSegment,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.testTag(PlayerSkipSegmentButtonTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -298,6 +282,16 @@ private fun BottomSection(
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PlayerControlsOverlayTag = "player-controls-overlay"
|
||||
internal const val PlayerQueueButtonTag = "player_queue_button"
|
||||
internal const val PlayerSeekBarTag = "player_seek_bar"
|
||||
internal const val PlayerPreviousButtonTag = "player_previous_button"
|
||||
internal const val PlayerSeekBackwardButtonTag = "player_seek_backward_button"
|
||||
internal const val PlayerPlayPauseButtonTag = "player_play_pause_button"
|
||||
internal const val PlayerSeekForwardButtonTag = "player_seek_forward_button"
|
||||
internal const val PlayerNextButtonTag = "player_next_button"
|
||||
internal const val PlayerSkipSegmentButtonTag = "player_skip_segment_button"
|
||||
|
||||
@Composable
|
||||
fun SkipSegmentButton(
|
||||
onClick: () -> Unit,
|
||||
@@ -329,6 +323,50 @@ fun SkipSegmentButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PlayerTimeRow(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
isLive: Boolean,
|
||||
onSeekLiveEdge: (() -> Unit)?,
|
||||
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.bodySmall
|
||||
)
|
||||
if (isLive) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "LIVE", color = scheme.primary, fontWeight = FontWeight.Bold)
|
||||
if (onSeekLiveEdge != null) {
|
||||
Text(
|
||||
text = "Catch up",
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.clickable { onSeekLiveEdge() }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = formatTime(durationMs),
|
||||
color = scheme.onSurface,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(positionMs: Long): String {
|
||||
val totalSeconds = positionMs / 1000
|
||||
val seconds = (totalSeconds % 60).toInt()
|
||||
|
||||
@@ -28,7 +28,7 @@ fun PlayerGesturesLayer(
|
||||
onDoubleTapLeft: () -> Unit,
|
||||
onVerticalDragLeft: (delta: Float) -> Unit,
|
||||
onVerticalDragRight: (delta: Float) -> Unit,
|
||||
onHorizontalDragPreview: (deltaMs: Long?) -> Unit = {},
|
||||
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
|
||||
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
|
||||
currentPositionProvider: () -> Long,
|
||||
) {
|
||||
@@ -95,7 +95,10 @@ fun PlayerGesturesLayer(
|
||||
val deltaMs = HorizontalSeekGestureHelper.deltaMs(accumulatedHorizontalDrag)
|
||||
if (deltaMs != 0L && deltaMs != lastPreviewDelta) {
|
||||
lastPreviewDelta = deltaMs
|
||||
onHorizontalDragPreview(deltaMs)
|
||||
onHorizontalDragPreview(
|
||||
deltaMs,
|
||||
(startPositionMs + deltaMs).coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,10 +120,10 @@ fun PlayerGesturesLayer(
|
||||
if (deltaMs != 0L) {
|
||||
val targetMs = (startPositionMs + deltaMs).coerceAtLeast(0L)
|
||||
onHorizontalDragSeekTo(targetMs)
|
||||
onHorizontalDragPreview(deltaMs)
|
||||
onHorizontalDragPreview(deltaMs, targetMs)
|
||||
}
|
||||
}
|
||||
onHorizontalDragPreview(null)
|
||||
onHorizontalDragPreview(null, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,7 +41,12 @@ fun PlayerQueuePanel(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(PlayerPlaylistPanelTag),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
@@ -78,13 +84,23 @@ fun PlayerQueuePanel(
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.testTag(PlayerPlaylistRowTag)
|
||||
) {
|
||||
items(uiState.queue, key = { item -> item.id }) { item ->
|
||||
QueueRow(
|
||||
title = item.title,
|
||||
artworkUrl = item.artworkUrl,
|
||||
isCurrent = item.isCurrent,
|
||||
onClick = { onSelect(item.id) }
|
||||
onClick = { onSelect(item.id) },
|
||||
modifier = Modifier.testTag(
|
||||
if (item.isCurrent) {
|
||||
PlayerPlaylistCurrentItemTag
|
||||
} else {
|
||||
"${PlayerPlaylistItemTagPrefix}${item.id}"
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,11 +122,12 @@ private fun QueueRow(
|
||||
subtitle: String? = null,
|
||||
artworkUrl: String?,
|
||||
isCurrent: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
@@ -160,3 +177,8 @@ private fun QueueRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PlayerPlaylistPanelTag = "player_playlist_panel"
|
||||
internal const val PlayerPlaylistRowTag = "player_playlist_row"
|
||||
internal const val PlayerPlaylistCurrentItemTag = "player_playlist_current_item"
|
||||
internal const val PlayerPlaylistItemTagPrefix = "player_playlist_item_"
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -63,7 +64,7 @@ fun QualitySelectionButton(
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
modifier = modifier.testTag(PlayerQualityButtonTag)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ fun AudioSelectionButton(
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
modifier = modifier.testTag(PlayerAudioButtonTag)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ fun SubtitlesSelectionButton(
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
modifier = modifier.testTag(PlayerSubtitlesButtonTag)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,6 +140,7 @@ private fun TrackSelectionPanel(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(PlayerTrackPanelTag)
|
||||
.fillMaxHeight()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.navigationBarsPadding()
|
||||
@@ -199,12 +201,20 @@ private fun TrackSelectionPanel(
|
||||
.heightIn(max = 292.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
options.forEach { option ->
|
||||
options.forEachIndexed { index, option ->
|
||||
val selected = option.id == selectedId
|
||||
TrackOptionItem(
|
||||
label = option.label,
|
||||
selected = selected,
|
||||
onClick = { onSelect(option) }
|
||||
onClick = { onSelect(option) },
|
||||
modifier = Modifier.testTag(
|
||||
when {
|
||||
selected -> PlayerTrackSelectedItemTag
|
||||
index == 0 -> PlayerTrackFirstItemTag
|
||||
index == options.lastIndex -> PlayerTrackLastItemTag
|
||||
else -> "$PlayerTrackItemTagPrefix$index"
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +223,15 @@ private fun TrackSelectionPanel(
|
||||
}
|
||||
}
|
||||
|
||||
internal const val PlayerQualityButtonTag = "player_quality_button"
|
||||
internal const val PlayerAudioButtonTag = "player_audio_button"
|
||||
internal const val PlayerSubtitlesButtonTag = "player_subtitles_button"
|
||||
internal const val PlayerTrackPanelTag = "player_track_panel"
|
||||
internal const val PlayerTrackSelectedItemTag = "player_track_selected_item"
|
||||
internal const val PlayerTrackFirstItemTag = "player_track_first_item"
|
||||
internal const val PlayerTrackLastItemTag = "player_track_last_item"
|
||||
internal const val PlayerTrackItemTagPrefix = "player_track_item_"
|
||||
|
||||
@Composable
|
||||
private fun TrackOptionItem(
|
||||
label: String,
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
package hu.bbara.purefin.ui.screen.series
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.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.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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -34,15 +21,13 @@ import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
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.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.ui.common.media.MediaHero
|
||||
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
|
||||
import hu.bbara.purefin.ui.common.media.MediaSynopsis
|
||||
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination
|
||||
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
|
||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
||||
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
|
||||
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
|
||||
@@ -60,33 +45,41 @@ fun SeriesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(series.id) {
|
||||
viewModel.selectSeries(series.id)
|
||||
LaunchedEffect(series) {
|
||||
viewModel.selectSeries(series)
|
||||
}
|
||||
|
||||
val series = viewModel.series.collectAsState()
|
||||
val seriesState = viewModel.series.collectAsStateWithLifecycle()
|
||||
|
||||
val seriesData = series.value
|
||||
val seriesData = seriesState.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
LaunchedEffect(seriesData) {
|
||||
viewModel.observeSeriesDownloadState(seriesData)
|
||||
}
|
||||
SeriesScreenInternal(
|
||||
series = seriesData,
|
||||
seriesDownloadState = viewModel.seriesDownloadState.collectAsState().value,
|
||||
seasonDownloadState = viewModel.seasonDownloadState.collectAsState().value,
|
||||
seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value,
|
||||
seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value,
|
||||
isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value,
|
||||
onDownloadOptionSelected = { option, selectedSeason ->
|
||||
when (option) {
|
||||
SeriesDownloadOption.SEASON ->
|
||||
viewModel.downloadSeason(selectedSeason.episodes)
|
||||
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
|
||||
|
||||
SeriesDownloadOption.SERIES ->
|
||||
viewModel.downloadSeries(seriesData)
|
||||
|
||||
SeriesDownloadOption.SMART ->
|
||||
viewModel.enableSmartDownload(seriesData.id)
|
||||
|
||||
SeriesDownloadOption.DELETE_SMART ->
|
||||
viewModel.deleteSmartDownloads(seriesData.id)
|
||||
}
|
||||
},
|
||||
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
|
||||
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
|
||||
onBack = viewModel::onGoHome,
|
||||
offline = series.offline,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
@@ -99,156 +92,98 @@ private fun SeriesScreenInternal(
|
||||
series: Series,
|
||||
seriesDownloadState: DownloadState,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
|
||||
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
|
||||
onObserveSeasonDownloadState: (List<Episode>) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
offline: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
|
||||
|
||||
fun getDefaultSeason() : Season {
|
||||
for (season in series.seasons) {
|
||||
val firstUnwatchedEpisode = season.episodes.firstOrNull {
|
||||
it.watched.not()
|
||||
}
|
||||
if (firstUnwatchedEpisode != null) {
|
||||
return season
|
||||
}
|
||||
}
|
||||
return series.seasons.first()
|
||||
fun getDefaultSeason(): Season {
|
||||
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first()
|
||||
}
|
||||
|
||||
var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) }
|
||||
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||
val nextUpEpisode = remember(series) {
|
||||
series.seasons.firstNotNullOfOrNull { season ->
|
||||
season.episodes.firstOrNull { !it.watched }
|
||||
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||
val selectedSeason =
|
||||
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
|
||||
val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched }
|
||||
?: selectedSeason.episodes.firstOrNull()
|
||||
|
||||
LaunchedEffect(series.id, selectedSeason.id) {
|
||||
onLoadSeasonEpisodes(series.id, selectedSeason.id)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedSeason.id, selectedSeason.episodes) {
|
||||
onObserveSeasonDownloadState(selectedSeason.episodes)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
MediaDetailScaffold(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(series.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
SeriesTopBar(
|
||||
onBack = onBack,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
SeriesTopBar(onBack = onBack)
|
||||
},
|
||||
heroContent = {
|
||||
SeriesHeroContent(series = series)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SeriesHeroSection(series = series)
|
||||
if (!isHomeMediaTransitionActive) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
SeriesActionButtons(
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
selectedSeason = selectedSeason,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
onDownloadOptionSelected = { option ->
|
||||
onDownloadOptionSelected(option, selectedSeason)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaSynopsis(
|
||||
synopsis = series.synopsis,
|
||||
bodyColor = scheme.onSurface,
|
||||
bodyFontSize = 13.sp,
|
||||
bodyLineHeight = null,
|
||||
titleSpacing = 8.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
onSelect = { selectedSeasonId = it.id }
|
||||
)
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if(series.cast.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CastRow(cast = series.cast)
|
||||
}
|
||||
}
|
||||
) {
|
||||
SeriesActionButtons(
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
seriesDownloadState = seriesDownloadState,
|
||||
selectedSeason = selectedSeason,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||
offline = offline,
|
||||
onDownloadOptionSelected = { option ->
|
||||
onDownloadOptionSelected(option, selectedSeason)
|
||||
}
|
||||
)
|
||||
MediaSynopsis(
|
||||
synopsis = series.synopsis,
|
||||
bodyColor = scheme.onSurface,
|
||||
bodyFontSize = 13.sp,
|
||||
bodyLineHeight = null,
|
||||
titleSpacing = 8.dp
|
||||
)
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
onSelect = { selectedSeasonId = it.id }
|
||||
)
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.episodes,
|
||||
)
|
||||
if (series.cast.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CastRow(cast = series.cast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesHeroSection(
|
||||
private fun SeriesHeroContent(
|
||||
series: Series,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||
val sectionHeight = screenHeight * 0.4f
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.homeMediaSharedBoundsDestination()
|
||||
.fillMaxWidth()
|
||||
.height(sectionHeight)
|
||||
) {
|
||||
MediaHero(
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(series.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 = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SeriesMetaChips(series = series)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
SeriesMetaChips(series = series)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@@ -259,9 +194,12 @@ private fun SeriesScreenPreview() {
|
||||
series = previewSeries(),
|
||||
seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f),
|
||||
seasonDownloadState = DownloadState.NotDownloaded,
|
||||
isSmartDownloadEnabled = true,
|
||||
onDownloadOptionSelected = { _, _ -> },
|
||||
onLoadSeasonEpisodes = { _, _ -> },
|
||||
onObserveSeasonDownloadState = {},
|
||||
onBack = {}
|
||||
onBack = {},
|
||||
offline = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ 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.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -34,6 +33,7 @@ import androidx.compose.material.icons.outlined.DownloadDone
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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
|
||||
@@ -76,29 +77,28 @@ 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.DefaultTopBar
|
||||
|
||||
@Composable
|
||||
internal fun SeriesTopBar(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
GhostIconButton(
|
||||
onClick = onBack,
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
|
||||
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
|
||||
}
|
||||
}
|
||||
DefaultTopBar(
|
||||
leftActions = {
|
||||
GhostIconButton(
|
||||
onClick = onBack,
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
contentDescription = "Back")
|
||||
},
|
||||
rightActions = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
|
||||
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
|
||||
}
|
||||
},
|
||||
withIcon = false
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -114,6 +114,8 @@ internal fun SeriesActionButtons(
|
||||
seriesDownloadState: DownloadState,
|
||||
selectedSeason: Season,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
offline: Boolean,
|
||||
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -121,8 +123,7 @@ internal fun SeriesActionButtons(
|
||||
val navigationManager = LocalNavigationManager.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
||||
val episodeId = nextUpEpisode?.id
|
||||
val playAction = remember(nextUpEpisode) {
|
||||
val playAction = remember(nextUpEpisode, offline) {
|
||||
nextUpEpisode?.let { episode ->
|
||||
{
|
||||
navigationManager.navigate(
|
||||
@@ -130,7 +131,8 @@ internal fun SeriesActionButtons(
|
||||
EpisodeDto(
|
||||
id = episode.id,
|
||||
seasonId = episode.seasonId,
|
||||
seriesId = episode.seriesId
|
||||
seriesId = episode.seriesId,
|
||||
offline = offline,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -141,20 +143,21 @@ internal fun SeriesActionButtons(
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (playAction != null && nextUpEpisode != null) {
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
MediaResumeButton(
|
||||
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
|
||||
onClick = playAction ?: {},
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.testTag(SeriesPlayButtonTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
MediaActionButton(
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
iconColor = MaterialTheme.colorScheme.onSurface,
|
||||
icon = Icons.Outlined.Add,
|
||||
height = 48.dp
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(SeriesAddButtonTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
MediaActionButton(
|
||||
@@ -166,6 +169,7 @@ internal fun SeriesActionButtons(
|
||||
else -> Icons.Outlined.Download
|
||||
},
|
||||
height = 48.dp,
|
||||
modifier = Modifier.testTag(SeriesDownloadButtonTag),
|
||||
onClick = { showDownloadDialog = true }
|
||||
)
|
||||
}
|
||||
@@ -174,6 +178,7 @@ internal fun SeriesActionButtons(
|
||||
DownloadOptionsDialog(
|
||||
selectedSeasonName = selectedSeason.name,
|
||||
seasonDownloadState = seasonDownloadState,
|
||||
isSmartDownloadEnabled = isSmartDownloadEnabled,
|
||||
onDownloadOptionSelected = {
|
||||
showDownloadDialog = false
|
||||
onDownloadOptionSelected(it)
|
||||
@@ -186,18 +191,21 @@ internal fun SeriesActionButtons(
|
||||
internal enum class SeriesDownloadOption {
|
||||
SEASON,
|
||||
SERIES,
|
||||
SMART
|
||||
SMART,
|
||||
DELETE_SMART
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadOptionsDialog(
|
||||
selectedSeasonName: String,
|
||||
seasonDownloadState: DownloadState,
|
||||
isSmartDownloadEnabled: Boolean,
|
||||
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(SeriesDownloadDialogTag),
|
||||
title = { Text("Download") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
@@ -209,7 +217,10 @@ private fun DownloadOptionsDialog(
|
||||
},
|
||||
confirmButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) }) {
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) },
|
||||
modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag)
|
||||
) {
|
||||
Text(
|
||||
when (seasonDownloadState) {
|
||||
is DownloadState.Downloaded -> "$selectedSeasonName Downloaded"
|
||||
@@ -218,14 +229,32 @@ private fun DownloadOptionsDialog(
|
||||
}
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) }) {
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) },
|
||||
modifier = Modifier.testTag(SeriesDownloadAllButtonTag)
|
||||
) {
|
||||
Text("Download All")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) }) {
|
||||
Text("Smart Download")
|
||||
if (isSmartDownloadEnabled) {
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) },
|
||||
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete Smart Downloads")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) },
|
||||
modifier = Modifier.testTag(SeriesSmartDownloadButtonTag)
|
||||
) {
|
||||
Text("Smart Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -245,11 +274,13 @@ internal fun SeasonTabs(
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
seasons.forEach { season ->
|
||||
seasons.forEachIndexed { index, season ->
|
||||
SeasonTab(
|
||||
name = season.name,
|
||||
isSelected = season == selectedSeason,
|
||||
modifier = Modifier.clickable { onSelect(season) }
|
||||
modifier = Modifier
|
||||
.testTag("$SeriesSeasonTabTagPrefix$index")
|
||||
.clickable { onSelect(season) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -300,7 +331,7 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
modifier = modifier.testTag(SeriesEpisodeCarouselTag),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||
@@ -319,6 +350,7 @@ private fun EpisodeCard(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.testTag("$SeriesEpisodeCardTagPrefix${episode.id}")
|
||||
.clickable { viewModel.onSelectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
@@ -403,6 +435,17 @@ private fun EpisodeCard(
|
||||
}
|
||||
}
|
||||
|
||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
||||
internal const val SeriesAddButtonTag = "series-add-button"
|
||||
internal const val SeriesDownloadButtonTag = "series-download-button"
|
||||
internal const val SeriesDownloadDialogTag = "series-download-dialog"
|
||||
internal const val SeriesDownloadSeasonButtonTag = "series-download-season-button"
|
||||
internal const val SeriesDownloadAllButtonTag = "series-download-all-button"
|
||||
internal const val SeriesSmartDownloadButtonTag = "series-smart-download-button"
|
||||
internal const val SeriesSeasonTabTagPrefix = "series-season-tab-"
|
||||
internal const val SeriesEpisodeCarouselTag = "series-episode-carousel"
|
||||
internal const val SeriesEpisodeCardTagPrefix = "series-episode-card-"
|
||||
|
||||
@Composable
|
||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
//TODO fix
|
||||
|
||||
@@ -1,105 +1,229 @@
|
||||
package hu.bbara.purefin.ui.screen.settings
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
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.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import hu.bbara.purefin.core.feature.settings.SettingsViewModel
|
||||
import hu.bbara.purefin.core.settings.SettingsOptions
|
||||
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 hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
|
||||
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
|
||||
import hu.bbara.purefin.ui.screen.settings.components.BooleanSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.NumberSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.DropdownSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.RangeSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.ReadOnlySettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.StringSettingItem
|
||||
import hu.bbara.purefin.ui.screen.settings.components.VoidSettingItem
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val settingGroups by viewModel.settingGroups.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.snackbarMessages.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
SettingsTopBar(onBack = viewModel::onBack)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Playback",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsOptions.numberSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
NumberSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
valueRange = option.valueRange,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
settingGroups.forEachIndexed { groupIndex, group ->
|
||||
group.title?.let { title ->
|
||||
item(key = "${groupIndex}-title") {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
top = 8.dp,
|
||||
end = 16.dp,
|
||||
bottom = 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.booleanSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
BooleanSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
itemsIndexed(
|
||||
items = group.options,
|
||||
key = { _, option -> option.key }
|
||||
) { index, option ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
shape = groupedSettingItemShape(
|
||||
index = index,
|
||||
itemCount = group.options.size
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsOptions.stringSettings.forEach { option ->
|
||||
item(key = option.key) {
|
||||
val value by viewModel.value(option).collectAsState(initial = option.defaultValue)
|
||||
StringSettingItem(
|
||||
title = option.title,
|
||||
value = value,
|
||||
onValueChange = { viewModel.set(option, it) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
if (groupIndex < settingGroups.lastIndex) {
|
||||
item(key = "${groupIndex}-spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupedSettingItemShape(
|
||||
index: Int,
|
||||
itemCount: Int
|
||||
): RoundedCornerShape {
|
||||
val cornerRadius = 12.dp
|
||||
return when {
|
||||
itemCount == 1 -> RoundedCornerShape(cornerRadius)
|
||||
index == 0 -> RoundedCornerShape(
|
||||
topStart = cornerRadius,
|
||||
topEnd = cornerRadius,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 0.dp
|
||||
)
|
||||
|
||||
index == itemCount - 1 -> RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
topEnd = 0.dp,
|
||||
bottomStart = cornerRadius,
|
||||
bottomEnd = cornerRadius
|
||||
)
|
||||
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingOptionItem(
|
||||
option: SettingOption<*>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
when (option) {
|
||||
is RangeSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
RangeSettingItem(
|
||||
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 {
|
||||
BooleanSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is StringSetting -> {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
StringSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReadOnlySetting -> {
|
||||
ReadOnlySettingItem(
|
||||
title = option.title,
|
||||
value = option.value
|
||||
)
|
||||
}
|
||||
|
||||
is VoidSetting -> {
|
||||
VoidSettingItem(
|
||||
title = option.title,
|
||||
onClick = { viewModel.onClick(option) }
|
||||
)
|
||||
}
|
||||
|
||||
is DropdownSetting<*> -> {
|
||||
DropdownSettingOptionItem(
|
||||
option = option,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> DropdownSettingOptionItem(
|
||||
option: DropdownSetting<T>,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
val value by viewModel.value(option).collectAsStateWithLifecycle(initialValue = null)
|
||||
value?.let {
|
||||
DropdownSettingItem(
|
||||
title = option.title,
|
||||
value = it,
|
||||
options = option.options,
|
||||
onValueChange = { value -> viewModel.set(option, value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsTopBar(
|
||||
onBack: () -> Unit
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
||||
@Composable
|
||||
fun BooleanSettingItem(
|
||||
title: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -25,9 +28,14 @@ fun BooleanSettingItem(
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = value,
|
||||
onCheckedChange = onValueChange
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
modifier = modifier.clickable { onValueChange(!value) }
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = modifier.toggleable(
|
||||
value = value,
|
||||
role = Role.Switch,
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> DropdownSettingItem(
|
||||
title: String,
|
||||
value: T,
|
||||
options: List<T>,
|
||||
onValueChange: (T) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value.toString(),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option.toString()) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onValueChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,12 @@ import androidx.compose.ui.unit.dp
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun NumberSettingItem(
|
||||
fun RangeSettingItem(
|
||||
title: String,
|
||||
value: Double,
|
||||
valueRange: ClosedFloatingPointRange<Double>,
|
||||
onValueChange: (Double) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var sliderValue by remember(value) { mutableStateOf(value.toFloat()) }
|
||||
|
||||
@@ -30,14 +30,14 @@ fun NumberSettingItem(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = String.Companion.format(Locale.US, "%.1f", sliderValue),
|
||||
text = String.format(Locale.US, "%.1f", sliderValue),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -46,7 +46,7 @@ fun NumberSettingItem(
|
||||
onValueChange = { sliderValue = it },
|
||||
onValueChangeFinished = { onValueChange(sliderValue.toDouble()) },
|
||||
valueRange = valueRange.start.toFloat()..valueRange.endInclusive.toFloat(),
|
||||
modifier = Modifier.Companion.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
fun ReadOnlySettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ fun StringSettingItem(
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
@@ -23,10 +23,10 @@ fun StringSettingItem(
|
||||
label = { Text(title) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Companion.Words
|
||||
capitalization = KeyboardCapitalization.Words
|
||||
),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package hu.bbara.purefin.ui.screen.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
fun VoidSettingItem(
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
android:translateY="88.3">
|
||||
|
||||
<path
|
||||
android:fillColor="#7c42f0"
|
||||
android:fillColor="@color/ic_launcher_foreground"
|
||||
android:pathData="M2068,4685c-72,-14 -140,-56 -183,-112 -67,-88 -65,-56 -65,-986l0,-840 68,5c80,7 95,10 104,23 4,6 8,375 7,822 0,884 -2,851 55,888 35,23 91,29 128,14 18,-7 123,-68 233,-135 110,-67 304,-185 430,-262 127,-77 231,-146 233,-154 2,-11 -9,-17 -40,-22 -67,-10 -236,-65 -298,-96 -150,-75 -322,-227 -376,-331 -25,-47 -29,-65 -29,-139 0,-64 5,-95 19,-123 26,-53 87,-112 146,-142 28,-14 50,-30 50,-35 0,-6 -14,-39 -31,-73 -21,-41 -59,-90 -113,-143 -121,-120 -260,-182 -466,-209 -25,-3 -63,-8 -85,-10 -258,-33 -399,-80 -520,-172 -57,-44 -143,-152 -184,-233 -36,-70 -75,-189 -66,-198 4,-4 10,-2 12,5 10,27 111,152 166,205 64,61 171,123 273,157 53,17 157,34 339,55 277,31 426,83 582,202 84,64 148,140 210,246 61,104 62,105 118,77 73,-37 74,-39 20,-132 -60,-103 -119,-180 -215,-280 -153,-161 -331,-266 -575,-342 -263,-81 -395,-137 -496,-211 -75,-54 -193,-186 -240,-265 -24,-41 -60,-117 -80,-169 -37,-94 -75,-248 -67,-272 3,-7 27,35 55,92 69,144 114,212 202,304 155,160 275,221 698,356 247,78 427,183 592,342 102,99 150,158 237,291 110,168 95,152 132,133 37,-19 102,-74 102,-87 0,-4 -32,-41 -71,-80 -94,-97 -163,-197 -263,-382 -105,-195 -146,-256 -207,-311 -94,-85 -172,-116 -409,-166 -80,-17 -167,-38 -195,-46 -82,-27 -198,-91 -256,-143 -53,-48 -137,-156 -128,-165 3,-3 27,13 54,35 122,98 253,141 495,164 198,19 216,21 305,47 165,48 287,143 390,303 18,28 60,105 93,170 120,236 287,445 355,445 25,0 99,-73 92,-91 -3,-8 -28,-30 -56,-49 -28,-19 -74,-60 -102,-90 -102,-112 -153,-215 -231,-470 -102,-332 -179,-434 -451,-595 -83,-48 -195,-114 -250,-147 -139,-83 -191,-90 -258,-37 -45,37 -57,84 -57,232 0,69 -4,128 -9,131 -11,7 -116,-29 -149,-50 -24,-16 -24,-17 -20,-153 3,-125 6,-141 31,-193 37,-76 119,-151 191,-173 109,-35 198,-10 400,109 50,30 145,85 211,123 189,109 283,189 367,311 42,60 101,191 133,292 75,238 91,281 131,360 24,47 65,108 91,136 51,55 136,114 163,114 9,0 37,-16 62,-35 177,-138 388,-82 487,129 111,235 125,557 37,805 -9,24 -16,47 -16,52 0,18 37,8 85,-24 28,-17 84,-52 125,-76 108,-64 444,-267 517,-313 92,-58 116,-128 72,-203 -16,-27 -51,-53 -143,-107 -66,-39 -175,-102 -241,-141 -66,-39 -145,-85 -175,-102 -30,-18 -100,-59 -155,-92 -55,-33 -143,-84 -195,-115 -52,-30 -183,-107 -290,-170 -107,-63 -249,-145 -316,-181l-120,-66 -18,-56c-14,-45 -51,-132 -84,-197 -9,-18 27,-5 83,28 33,21 114,68 180,107 66,38 183,107 260,152 259,153 679,398 845,493 468,269 493,287 541,387 57,119 32,253 -66,350 -22,22 -117,86 -210,141 -94,56 -343,206 -555,334 -212,128 -428,259 -480,290 -154,93 -587,356 -835,507 -583,354 -665,403 -692,414 -48,20 -118,26 -175,14zM3375,3729c381,-94 641,-440 634,-844 -4,-220 -69,-404 -160,-451 -56,-28 -87,-8 -284,185 -253,248 -359,330 -619,475 -49,27 -136,68 -195,92 -172,71 -214,95 -234,137 -29,58 -11,96 92,197 204,200 487,277 766,209z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#7C42F0"
|
||||
android:fillColor="@color/ic_launcher_foreground"
|
||||
android:pathData="M3754,3278c14,-45 28,-231 22,-298 -3,-42 -18,-116 -32,-164 -27,-94 -25,-118 11,-142 66,-43 121,70 119,251 -1,87 -30,208 -69,285 -26,51 -60,96 -51,68z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#131216</color>
|
||||
<color name="ic_launcher_foreground">#7C42F0</color>
|
||||
</resources>
|
||||
@@ -5,6 +5,7 @@ import java.util.UUID
|
||||
|
||||
sealed class PlayableMedia {
|
||||
abstract val id: UUID
|
||||
abstract val preferenceMediaId: UUID
|
||||
abstract val resumePositionMs: Long
|
||||
abstract val mediaItem: MediaItem
|
||||
abstract val preferences: MediaTrackPreferences
|
||||
@@ -12,6 +13,7 @@ sealed class PlayableMedia {
|
||||
|
||||
data class Movie(
|
||||
override val id: UUID,
|
||||
override val preferenceMediaId: UUID,
|
||||
override val resumePositionMs: Long,
|
||||
override val mediaItem: MediaItem,
|
||||
override val preferences: MediaTrackPreferences,
|
||||
@@ -20,6 +22,7 @@ sealed class PlayableMedia {
|
||||
|
||||
data class Series(
|
||||
override val id: UUID,
|
||||
override val preferenceMediaId: UUID,
|
||||
override val resumePositionMs: Long,
|
||||
override val mediaItem: MediaItem,
|
||||
override val preferences: MediaTrackPreferences,
|
||||
@@ -28,6 +31,7 @@ sealed class PlayableMedia {
|
||||
|
||||
data class Episode(
|
||||
override val id: UUID,
|
||||
override val preferenceMediaId: UUID,
|
||||
override val resumePositionMs: Long,
|
||||
override val mediaItem: MediaItem,
|
||||
override val preferences: MediaTrackPreferences,
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import hu.bbara.purefin.R
|
||||
import hu.bbara.purefin.core.ui.R
|
||||
|
||||
@Composable
|
||||
fun PurefinLogo(
|
||||
@@ -1,7 +1,7 @@
|
||||
package hu.bbara.purefin.ui.common.media
|
||||
|
||||
fun mediaPlayButtonText(progressPercent: Double?, watched: Boolean): String {
|
||||
return if ((progressPercent ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
fun mediaPlayButtonText(progressPercent: Double?, watched: Boolean?): String {
|
||||
return if ((progressPercent ?: 0.0) > 0.0 && watched?.not() == true) "Resume" else "Play"
|
||||
}
|
||||
|
||||
fun mediaPlaybackProgress(progressPercent: Double?): Float {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package hu.bbara.purefin.ui.screen.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.common.button.PurefinTextButton
|
||||
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField
|
||||
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
|
||||
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
||||
|
||||
@Immutable
|
||||
data class LoginContentState(
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
class LoginContentCallbacks(
|
||||
val onServerUrlChange: (String) -> Unit,
|
||||
val onUsernameChange: (String) -> Unit,
|
||||
val onPasswordChange: (String) -> Unit,
|
||||
val onConnect: () -> Unit,
|
||||
val onDiscoverServers: () -> Unit = {},
|
||||
val onNeedHelp: () -> Unit = {}
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoginContent(
|
||||
state: LoginContentState,
|
||||
callbacks: LoginContentCallbacks,
|
||||
isLoggingIn: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
if (isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie,
|
||||
contentDescription = "Logo",
|
||||
tint = scheme.onPrimary,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Jellyfin",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "PERSONAL MEDIA SYSTEM",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.align(Alignment.Start)
|
||||
)
|
||||
Text(
|
||||
text = "Enter your details to access your library",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
state.errorMessage?.let { errorMessage ->
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = scheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
scheme.errorContainer,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Server URL",
|
||||
value = state.serverUrl,
|
||||
onValueChange = callbacks.onServerUrlChange,
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Username",
|
||||
value = state.username,
|
||||
onValueChange = callbacks.onUsernameChange,
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinPasswordField(
|
||||
label = "Password",
|
||||
value = state.password,
|
||||
onValueChange = callbacks.onPasswordChange,
|
||||
placeholder = "••••••••",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text("Connect") },
|
||||
onClick = callbacks.onConnect
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = callbacks.onDiscoverServers) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(" Discover Servers", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = callbacks.onNeedHelp) {
|
||||
Text("Need Help?", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package hu.bbara.purefin.ui.screen.login
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
enum class LoginContentPhase {
|
||||
ServerSearch,
|
||||
Login
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LoginServerCandidateUi(
|
||||
val name: String?,
|
||||
val address: String
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LoginContentState(
|
||||
val phase: LoginContentPhase,
|
||||
val serverUrl: String,
|
||||
val selectedServerName: String?,
|
||||
val selectedServerUrl: String?,
|
||||
val discoveredServers: List<LoginServerCandidateUi>,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val isSearching: Boolean,
|
||||
val isLoggingIn: Boolean,
|
||||
val quickConnectAvailable: Boolean,
|
||||
val quickConnectCode: String?,
|
||||
val isQuickConnecting: Boolean,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
class LoginContentCallbacks(
|
||||
val onServerUrlChange: (String) -> Unit,
|
||||
val onFindServer: () -> Unit,
|
||||
val onDiscoveredServerClick: (LoginServerCandidateUi) -> Unit,
|
||||
val onChangeServer: () -> Unit,
|
||||
val onUsernameChange: (String) -> Unit,
|
||||
val onPasswordChange: (String) -> Unit,
|
||||
val onConnect: () -> Unit,
|
||||
val onQuickConnect: () -> Unit,
|
||||
val onCancelQuickConnect: () -> Unit
|
||||
)
|
||||
@@ -41,5 +41,6 @@ dependencies {
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.timber)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class JellyfinServerCandidate(
|
||||
val name: String?,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class QuickConnectSession(
|
||||
val code: String,
|
||||
val secret: String,
|
||||
val authenticated: Boolean
|
||||
)
|
||||
|
||||
interface AuthenticationRepository {
|
||||
fun discoverServers(): Flow<JellyfinServerCandidate>
|
||||
suspend fun findServer(input: String): JellyfinServerCandidate?
|
||||
suspend fun isQuickConnectEnabled(url: String): Boolean
|
||||
suspend fun initiateQuickConnect(url: String): QuickConnectSession?
|
||||
suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession?
|
||||
suspend fun login(url: String, username: String, password: String): Boolean
|
||||
suspend fun loginWithQuickConnect(url: String, secret: String): Boolean
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -58,13 +59,17 @@ class CompositeLocalMediaRepository @Inject constructor(
|
||||
.flatMapLatest { it.getEpisode(id) }
|
||||
}
|
||||
|
||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||
return activeRepository.flatMapLatest { it.observeSeriesWithContent(seriesId) }
|
||||
override suspend fun loadSeasons(seriesId: UUID) {
|
||||
activeRepository.first().loadSeasons(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
val repository = onlineRepository
|
||||
repository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||
}
|
||||
|
||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||
|
||||
@@ -14,5 +14,6 @@ interface LocalMediaRepository : LocalMediaUpdater {
|
||||
suspend fun getMovie(id: UUID): Flow<Movie?>
|
||||
suspend fun getSeries(id: UUID): Flow<Series?>
|
||||
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
||||
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
|
||||
suspend fun loadSeasons(seriesId: UUID)
|
||||
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
|
||||
}
|
||||
|
||||
@@ -4,4 +4,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkMonitor {
|
||||
val isOnline: Flow<Boolean>
|
||||
|
||||
fun reportRequestSucceeded()
|
||||
|
||||
fun reportRequestFailed(error: Throwable)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface UserSessionRepository {
|
||||
|
||||
val isLoggedIn: Flow<Boolean>
|
||||
suspend fun setLoggedIn(isLoggedIn: Boolean)
|
||||
suspend fun logout()
|
||||
|
||||
val isOfflineMode: Flow<Boolean>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
@@ -7,11 +8,14 @@ import java.util.UUID
|
||||
interface MediaDownloadController {
|
||||
fun observeActiveDownloads(): Flow<Map<String, Float>>
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
|
||||
fun getCompletedDownloadMediaItem(contentId: String): MediaItem?
|
||||
suspend fun downloadMovie(movieId: UUID)
|
||||
suspend fun cancelDownload(movieId: UUID)
|
||||
suspend fun downloadEpisode(episodeId: UUID)
|
||||
suspend fun downloadEpisodes(episodeIds: List<UUID>)
|
||||
suspend fun cancelEpisodeDownload(episodeId: UUID)
|
||||
suspend fun enableSmartDownload(seriesId: UUID)
|
||||
suspend fun deleteSmartDownloads(seriesId: UUID)
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean>
|
||||
suspend fun syncSmartDownloads()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
@@ -51,16 +52,16 @@ class MediaDownloadManager @Inject constructor(
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
Timber.tag(TAG).d("Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
Timber.tag(TAG).e(finalException, "Download exception for $contentId")
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
Timber.tag(TAG).d("Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
@@ -90,7 +91,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
}
|
||||
emit(result)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to poll active downloads", e)
|
||||
Timber.tag(TAG).e(e, "Failed to poll active downloads")
|
||||
emit(emptyMap())
|
||||
}
|
||||
delay(500)
|
||||
@@ -109,17 +110,23 @@ class MediaDownloadManager @Inject constructor(
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
override fun getCompletedDownloadMediaItem(contentId: String): MediaItem? {
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId) ?: return null
|
||||
if (download.state != Download.STATE_COMPLETED) return null
|
||||
return download.request.toMediaItem()
|
||||
}
|
||||
|
||||
override suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
|
||||
Log.e(TAG, "No downloadable movie source for $movieId")
|
||||
Timber.tag(TAG).e("No downloadable movie source for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveMovies(listOf(source.movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
|
||||
Timber.tag(TAG).d("Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = movieId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
@@ -127,9 +134,9 @@ class MediaDownloadManager @Inject constructor(
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
Timber.tag(TAG).d("Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
Timber.tag(TAG).e(e, "Failed to start download for $movieId")
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
@@ -141,7 +148,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
try {
|
||||
offlineCatalogStore.deleteMovie(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
Timber.tag(TAG).e(e, "Failed to remove movie from offline DB")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +157,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
|
||||
Log.e(TAG, "No downloadable episode source for $episodeId")
|
||||
Timber.tag(TAG).e("No downloadable episode source for $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -164,7 +171,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
|
||||
offlineCatalogStore.saveEpisode(source.episode)
|
||||
|
||||
Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
|
||||
Timber.tag(TAG).d("Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = episodeId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
@@ -172,9 +179,9 @@ class MediaDownloadManager @Inject constructor(
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for episode $episodeId")
|
||||
Timber.tag(TAG).d("Download request sent for episode $episodeId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for episode $episodeId", e)
|
||||
Timber.tag(TAG).e(e, "Failed to start download for episode $episodeId")
|
||||
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
@@ -194,7 +201,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
try {
|
||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove episode from offline DB", e)
|
||||
Timber.tag(TAG).e(e, "Failed to remove episode from offline DB")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,11 +213,16 @@ class MediaDownloadManager @Inject constructor(
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
}
|
||||
|
||||
suspend fun disableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
override suspend fun deleteSmartDownloads(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
offlineCatalogStore.getEpisodesBySeries(seriesId).forEach { episode ->
|
||||
cancelEpisodeDownload(episode.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
|
||||
override fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
|
||||
|
||||
override suspend fun syncSmartDownloads() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -219,7 +231,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
try {
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
|
||||
Timber.tag(TAG).e(e, "Smart download sync failed for series $seriesId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +246,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||
for (episode in downloadedEpisodes) {
|
||||
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
|
||||
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
|
||||
Timber.tag(TAG).d("Smart download: removing watched episode ${episode.title}")
|
||||
cancelEpisodeDownload(episode.id)
|
||||
} else {
|
||||
unwatchedDownloaded.add(episode.id)
|
||||
@@ -252,7 +264,7 @@ class MediaDownloadManager @Inject constructor(
|
||||
)
|
||||
|
||||
if (toDownload.isNotEmpty()) {
|
||||
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
|
||||
Timber.tag(TAG).d("Smart download: queuing ${toDownload.size} episodes for series $seriesId")
|
||||
downloadEpisodes(toDownload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.settings.SettingsRepository
|
||||
import hu.bbara.purefin.core.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
@@ -40,11 +42,20 @@ class AppViewModel @Inject constructor(
|
||||
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
networkMonitor: NetworkMonitor,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false
|
||||
)
|
||||
|
||||
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
@@ -138,26 +149,31 @@ class AppViewModel @Inject constructor(
|
||||
homeRepository.latestLibraryContent,
|
||||
localMediaRepository.movies,
|
||||
localMediaRepository.series,
|
||||
localMediaRepository.episodes
|
||||
) { libraryMap, moviesMap, seriesMap, episodesMap ->
|
||||
libraryMap.mapValues { (_, items) ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
MovieUiModel(movie = it)
|
||||
localMediaRepository.episodes,
|
||||
settingsRepository.settings,
|
||||
) { libraryMap, moviesMap, seriesMap, episodesMap, settings ->
|
||||
libraryMap
|
||||
.filter { (libraryId, _) ->
|
||||
settings.booleanSettings["home_library_visible_$libraryId"] ?: true
|
||||
}
|
||||
.mapValues { (_, items) ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
MovieUiModel(movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
@@ -278,7 +294,7 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
userSessionRepository.setLoggedIn(false)
|
||||
userSessionRepository.logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,58 @@ package hu.bbara.purefin.core.feature.content.episode
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: LocalMediaRepository,
|
||||
private val defaultMediaCatalogReader: LocalMediaRepository,
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episodeId = MutableStateFlow<UUID?>(null)
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
private val _episode = MutableStateFlow<EpisodeDto?>(null)
|
||||
|
||||
val episode: StateFlow<Episode?> = combine(
|
||||
_episodeId,
|
||||
mediaCatalogReader.episodes
|
||||
) { id, episodesMap ->
|
||||
id?.let { episodesMap[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val episode: StateFlow<Episode?> = _episode
|
||||
.flatMapLatest { episode ->
|
||||
if (episode == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
mediaCatalogReader(episode.offline).getEpisode(episode.id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val seriesTitle: StateFlow<String?> = combine(
|
||||
_seriesId,
|
||||
mediaCatalogReader.series
|
||||
) { seriesId, seriesMap ->
|
||||
seriesId?.let { id -> seriesMap[id]?.name }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val seriesTitle: StateFlow<String?> = _episode
|
||||
.flatMapLatest { episode ->
|
||||
if (episode == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
mediaCatalogReader(episode.offline).getSeries(episode.seriesId).map { it?.name }
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||
@@ -52,32 +64,44 @@ class EpisodeScreenViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun onSeriesClick() {
|
||||
val seriesId = _seriesId.value ?: return
|
||||
navigationManager.navigate(Route.SeriesRoute(SeriesDto(id = seriesId)))
|
||||
val episode = _episode.value ?: return
|
||||
navigationManager.navigate(
|
||||
Route.SeriesRoute(
|
||||
SeriesDto(id = episode.seriesId, offline = episode.offline)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
_episodeId.value = episodeId
|
||||
_seriesId.value = seriesId
|
||||
fun selectEpisode(episode: EpisodeDto) {
|
||||
_episode.value = episode
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.observeDownloadState(episodeId.toString()).collect {
|
||||
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
|
||||
_downloadState.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val episodeId = _episodeId.value ?: return
|
||||
val episodeId = _episode.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
when (_downloadState.value) {
|
||||
is DownloadState.NotDownloaded, is DownloadState.Failed -> {
|
||||
mediaDownloadManager.downloadEpisode(episodeId)
|
||||
}
|
||||
is DownloadState.Downloading, is DownloadState.Downloaded -> {
|
||||
is DownloadState.Downloading -> {
|
||||
mediaDownloadManager.cancelEpisodeDownload(episodeId)
|
||||
}
|
||||
is DownloadState.Downloaded -> {
|
||||
mediaDownloadManager.cancelEpisodeDownload(episodeId)
|
||||
if (_episode.value?.offline == true) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
|
||||
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package hu.bbara.purefin.core.feature.content.episode
|
||||
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class SeriesIdLookUpUseCase @Inject constructor(
|
||||
private val localMediaRepository: LocalMediaRepository,
|
||||
) {
|
||||
suspend operator fun invoke(episodeId: UUID): UUID? {
|
||||
return localMediaRepository.getEpisode(episodeId).first()?.seriesId
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,45 @@ package hu.bbara.purefin.core.feature.content.movie
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MovieScreenViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: LocalMediaRepository,
|
||||
private val defaultMediaCatalogReader: LocalMediaRepository,
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
): ViewModel() {
|
||||
|
||||
private val _movieId = MutableStateFlow<UUID?>(null)
|
||||
private val _movie = MutableStateFlow<MovieDto?>(null)
|
||||
|
||||
val movie: StateFlow<Movie?> = combine(
|
||||
_movieId,
|
||||
mediaCatalogReader.movies
|
||||
) { movieId, movies ->
|
||||
movieId?.let { movies[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val movie: StateFlow<Movie?> = _movie
|
||||
.flatMapLatest { movie ->
|
||||
if (movie == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
mediaCatalogReader(movie.offline).getMovie(movie.id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||
@@ -51,10 +59,10 @@ class MovieScreenViewModel @Inject constructor(
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectMovie(movieId: UUID) {
|
||||
_movieId.value = movieId
|
||||
fun selectMovie(movie: MovieDto) {
|
||||
_movie.value = movie
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
|
||||
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
|
||||
_downloadState.value = it
|
||||
}
|
||||
}
|
||||
@@ -72,9 +80,15 @@ class MovieScreenViewModel @Inject constructor(
|
||||
}
|
||||
is DownloadState.Downloaded -> {
|
||||
mediaDownloadManager.cancelDownload(movieId)
|
||||
if (_movie.value?.offline == true) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
|
||||
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,50 +3,74 @@ package hu.bbara.purefin.core.feature.content.series
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.Offline
|
||||
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Series
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: LocalMediaRepository,
|
||||
private val defaultMediaCatalogReader: LocalMediaRepository,
|
||||
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
private val _series = MutableStateFlow<SeriesDto?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val series: StateFlow<Series?> = _seriesId
|
||||
.flatMapLatest { id ->
|
||||
if (id != null) mediaCatalogReader.observeSeriesWithContent(id) else flowOf(null)
|
||||
val series: StateFlow<Series?> = _series
|
||||
.flatMapLatest { series ->
|
||||
if (series == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
mediaCatalogReader(series.offline).getSeries(series.id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val isSmartDownloadEnabled: StateFlow<Boolean> = _series
|
||||
.flatMapLatest { series ->
|
||||
if (series == null) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
mediaDownloadManager.isSmartDownloadEnabled(series.id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
|
||||
|
||||
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
|
||||
|
||||
private var seasonDownloadStateJob: Job? = null
|
||||
private var seriesDownloadStateJob: Job? = null
|
||||
|
||||
fun observeSeasonDownloadState(episodes: List<Episode>) {
|
||||
viewModelScope.launch {
|
||||
seasonDownloadStateJob?.cancel()
|
||||
seasonDownloadStateJob = viewModelScope.launch {
|
||||
if (episodes.isEmpty()) {
|
||||
_seasonDownloadState.value = DownloadState.NotDownloaded
|
||||
return@launch
|
||||
@@ -58,9 +82,11 @@ class SeriesViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun observeSeriesDownloadState(series: Series) {
|
||||
viewModelScope.launch {
|
||||
seriesDownloadStateJob?.cancel()
|
||||
seriesDownloadStateJob = viewModelScope.launch {
|
||||
val allEpisodes = series.seasons.flatMap { it.episodes }
|
||||
if (allEpisodes.isEmpty()) {
|
||||
val hasUnloadedSeasons = series.seasons.any { it.episodes.isEmpty() && it.episodeCount > 0 }
|
||||
if (allEpisodes.isEmpty() || hasUnloadedSeasons) {
|
||||
_seriesDownloadState.value = DownloadState.NotDownloaded
|
||||
return@launch
|
||||
}
|
||||
@@ -70,8 +96,22 @@ class SeriesViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeason(episodes: List<Episode>) {
|
||||
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||
viewModelScope.launch {
|
||||
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeason(seriesId: UUID, seasonId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val mediaCatalogReader = selectedMediaCatalogReader()
|
||||
mediaCatalogReader.loadSeasonEpisodes(seriesId, seasonId)
|
||||
val episodes = mediaCatalogReader.getSeries(seriesId)
|
||||
.first()
|
||||
?.seasons
|
||||
?.firstOrNull { it.id == seasonId }
|
||||
?.episodes
|
||||
.orEmpty()
|
||||
mediaDownloadManager.downloadEpisodes(episodes.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -82,11 +122,26 @@ class SeriesViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeries(series: Series) {
|
||||
fun deleteSmartDownloads(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val allEpisodeIds = series.seasons.flatMap { season ->
|
||||
season.episodes.map { it.id }
|
||||
mediaDownloadManager.deleteSmartDownloads(seriesId)
|
||||
if (_series.value?.offline == true) {
|
||||
navigationManager.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeries(seriesData: Series) {
|
||||
viewModelScope.launch {
|
||||
val mediaCatalogReader = selectedMediaCatalogReader()
|
||||
seriesData.seasons.forEach { season ->
|
||||
mediaCatalogReader.loadSeasonEpisodes(seriesData.id, season.id)
|
||||
}
|
||||
val allEpisodeIds = mediaCatalogReader.getSeries(seriesData.id)
|
||||
.first()
|
||||
?.seasons
|
||||
.orEmpty()
|
||||
.flatMap { season -> season.episodes.map { it.id } }
|
||||
mediaDownloadManager.downloadEpisodes(allEpisodeIds)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +165,8 @@ class SeriesViewModel @Inject constructor(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
seriesId = seriesId,
|
||||
offline = _series.value?.offline == true,
|
||||
)
|
||||
))
|
||||
}
|
||||
@@ -127,7 +183,18 @@ class SeriesViewModel @Inject constructor(
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectSeries(seriesId: UUID) {
|
||||
_seriesId.value = seriesId
|
||||
fun selectSeries(series: SeriesDto) {
|
||||
_series.value = series
|
||||
viewModelScope.launch {
|
||||
mediaCatalogReader(series.offline).loadSeasons(series.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedMediaCatalogReader(): LocalMediaRepository {
|
||||
return mediaCatalogReader(_series.value?.offline == true)
|
||||
}
|
||||
|
||||
private fun mediaCatalogReader(offline: Boolean): LocalMediaRepository {
|
||||
return if (offline) offlineMediaCatalogReader else defaultMediaCatalogReader
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DownloadsViewModel @Inject constructor(
|
||||
@Offline private val offlineCatalogReader: LocalMediaRepository,
|
||||
@param:Offline private val offlineCatalogReader: LocalMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val downloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
@@ -31,7 +31,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(
|
||||
Route.MovieRoute(
|
||||
MovieDto(id = movieId)
|
||||
MovieDto(id = movieId, offline = true)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class DownloadsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(
|
||||
Route.SeriesRoute(
|
||||
SeriesDto(id = seriesId)
|
||||
SeriesDto(id = seriesId, offline = true)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||
import hu.bbara.purefin.core.data.JellyfinServerCandidate
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -17,30 +21,57 @@ class LoginViewModel @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val authenticationRepository: AuthenticationRepository,
|
||||
) : ViewModel() {
|
||||
enum class Phase {
|
||||
SERVER_SEARCH,
|
||||
LOGIN
|
||||
}
|
||||
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
private val _password = MutableStateFlow("")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
private val _url = MutableStateFlow("")
|
||||
val url: StateFlow<String> = _url.asStateFlow()
|
||||
private val _phase = MutableStateFlow(Phase.SERVER_SEARCH)
|
||||
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
private val _selectedServer = MutableStateFlow<JellyfinServerCandidate?>(null)
|
||||
val selectedServer: StateFlow<JellyfinServerCandidate?> = _selectedServer.asStateFlow()
|
||||
private val _discoveredServers = MutableStateFlow<List<JellyfinServerCandidate>>(emptyList())
|
||||
val discoveredServers: StateFlow<List<JellyfinServerCandidate>> = _discoveredServers.asStateFlow()
|
||||
private val _isSearching = MutableStateFlow(false)
|
||||
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
|
||||
private val _isLoggingIn = MutableStateFlow(false)
|
||||
val isLoggingIn: StateFlow<Boolean> = _isLoggingIn.asStateFlow()
|
||||
private val _quickConnectAvailable = MutableStateFlow(false)
|
||||
val quickConnectAvailable: StateFlow<Boolean> = _quickConnectAvailable.asStateFlow()
|
||||
private val _quickConnectCode = MutableStateFlow<String?>(null)
|
||||
val quickConnectCode: StateFlow<String?> = _quickConnectCode.asStateFlow()
|
||||
private val _isQuickConnecting = MutableStateFlow(false)
|
||||
val isQuickConnecting: StateFlow<Boolean> = _isQuickConnecting.asStateFlow()
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private var quickConnectJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_url.value = userSessionRepository.serverUrl.first()
|
||||
}
|
||||
discoverServers()
|
||||
}
|
||||
|
||||
fun setUrl(url: String) {
|
||||
clearError()
|
||||
_url.value = url
|
||||
}
|
||||
|
||||
fun setUsername(username: String) {
|
||||
clearError()
|
||||
_username.value = username
|
||||
}
|
||||
|
||||
fun setPassword(password: String) {
|
||||
clearError()
|
||||
_password.value = password
|
||||
}
|
||||
|
||||
@@ -49,19 +80,144 @@ class LoginViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun clearFields() {
|
||||
userSessionRepository.setServerUrl("");
|
||||
cancelQuickConnect()
|
||||
userSessionRepository.setServerUrl("")
|
||||
_url.value = ""
|
||||
_username.value = ""
|
||||
_password.value = ""
|
||||
_selectedServer.value = null
|
||||
_phase.value = Phase.SERVER_SEARCH
|
||||
}
|
||||
|
||||
suspend fun login(): Boolean {
|
||||
cancelQuickConnect()
|
||||
_errorMessage.value = null
|
||||
userSessionRepository.setServerUrl(url.value)
|
||||
val success = authenticationRepository.login(url.value, username.value, password.value)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Login failed. Check your server URL, username, and password."
|
||||
_isLoggingIn.value = true
|
||||
return try {
|
||||
val serverUrl = selectedServer.value?.address ?: url.value
|
||||
userSessionRepository.setServerUrl(serverUrl)
|
||||
val success = authenticationRepository.login(serverUrl, username.value, password.value)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Login failed. Check your server URL, username, and password."
|
||||
}
|
||||
success
|
||||
} finally {
|
||||
_isLoggingIn.value = false
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun findServer() {
|
||||
viewModelScope.launch {
|
||||
val input = url.value.trim()
|
||||
if (input.isBlank()) {
|
||||
_errorMessage.value = "Enter a Jellyfin server address."
|
||||
return@launch
|
||||
}
|
||||
|
||||
cancelQuickConnect()
|
||||
_isSearching.value = true
|
||||
_errorMessage.value = null
|
||||
val server = authenticationRepository.findServer(input)
|
||||
_isSearching.value = false
|
||||
if (server == null) {
|
||||
_errorMessage.value = "No Jellyfin server found for that address."
|
||||
return@launch
|
||||
}
|
||||
selectServer(server)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectDiscoveredServer(server: JellyfinServerCandidate) {
|
||||
viewModelScope.launch {
|
||||
cancelQuickConnect()
|
||||
_errorMessage.value = null
|
||||
selectServer(server)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeServer() {
|
||||
cancelQuickConnect()
|
||||
_errorMessage.value = null
|
||||
_quickConnectAvailable.value = false
|
||||
_selectedServer.value = null
|
||||
_phase.value = Phase.SERVER_SEARCH
|
||||
}
|
||||
|
||||
fun startQuickConnect() {
|
||||
val serverUrl = selectedServer.value?.address ?: url.value
|
||||
if (serverUrl.isBlank()) {
|
||||
_errorMessage.value = "Select a Jellyfin server first."
|
||||
return
|
||||
}
|
||||
|
||||
quickConnectJob?.cancel()
|
||||
quickConnectJob = viewModelScope.launch {
|
||||
_errorMessage.value = null
|
||||
_isQuickConnecting.value = true
|
||||
_quickConnectCode.value = null
|
||||
try {
|
||||
var session = authenticationRepository.initiateQuickConnect(serverUrl) ?: run {
|
||||
_errorMessage.value = "Quick Connect is not available on this server."
|
||||
return@launch
|
||||
}
|
||||
|
||||
_quickConnectCode.value = session.code
|
||||
while (!session.authenticated) {
|
||||
delay(2_000L)
|
||||
val updatedSession = authenticationRepository.getQuickConnectState(serverUrl, session.secret)
|
||||
if (updatedSession == null) {
|
||||
_errorMessage.value = "Quick Connect failed. Try again or use manual login."
|
||||
return@launch
|
||||
}
|
||||
session = updatedSession
|
||||
_quickConnectCode.value = session.code
|
||||
}
|
||||
|
||||
userSessionRepository.setServerUrl(serverUrl)
|
||||
val success = authenticationRepository.loginWithQuickConnect(serverUrl, session.secret)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Quick Connect login failed. Try again or use manual login."
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} finally {
|
||||
_isQuickConnecting.value = false
|
||||
_quickConnectCode.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelQuickConnect() {
|
||||
quickConnectJob?.cancel()
|
||||
quickConnectJob = null
|
||||
_isQuickConnecting.value = false
|
||||
_quickConnectCode.value = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cancelQuickConnect()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun discoverServers() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
authenticationRepository.discoverServers().collect { server ->
|
||||
_discoveredServers.value = (_discoveredServers.value + server)
|
||||
.distinctBy { it.address }
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selectServer(server: JellyfinServerCandidate) {
|
||||
_selectedServer.value = server
|
||||
_url.value = server.address
|
||||
_phase.value = Phase.LOGIN
|
||||
userSessionRepository.setServerUrl(server.address)
|
||||
_quickConnectAvailable.value = authenticationRepository.isQuickConnectEnabled(server.address)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package hu.bbara.purefin.core.feature.settings
|
||||
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class HomeLibrarySettingsProvider @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
) : SettingsGroupProvider {
|
||||
|
||||
override val settingGroups: Flow<List<SettingGroup>> = homeRepository.libraries.map { libraries ->
|
||||
if (libraries.isEmpty()) return@map emptyList()
|
||||
|
||||
val options = libraries.map { library ->
|
||||
BooleanSetting(
|
||||
key = "home_library_visible_${library.id}",
|
||||
title = library.name,
|
||||
defaultValue = true
|
||||
)
|
||||
}
|
||||
|
||||
listOf(
|
||||
SettingGroup(
|
||||
title = "Home Screen Libraries",
|
||||
options = options
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package hu.bbara.purefin.core.feature.settings
|
||||
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LogoutSettingsProvider @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) : SettingsGroupProvider {
|
||||
|
||||
override val settingGroups: Flow<List<SettingGroup>> = flowOf(
|
||||
listOf(
|
||||
SettingGroup(
|
||||
title = "Account",
|
||||
options = listOf(
|
||||
VoidSetting(
|
||||
key = LOGOUT_KEY,
|
||||
title = "Log out",
|
||||
onClick = { userSessionRepository.logout() }
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val LOGOUT_KEY = "logout"
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,22 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.settings.BooleanSetting
|
||||
import hu.bbara.purefin.core.settings.NumberSetting
|
||||
import hu.bbara.purefin.core.settings.DropdownSetting
|
||||
import hu.bbara.purefin.core.settings.RangeSetting
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import hu.bbara.purefin.core.settings.SettingsOptions
|
||||
import hu.bbara.purefin.core.settings.SettingsRepository
|
||||
import hu.bbara.purefin.core.settings.StringSetting
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -15,15 +28,39 @@ import javax.inject.Inject
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
settingsGroupProviders: Set<@JvmSuppressWildcards SettingsGroupProvider>,
|
||||
) : ViewModel() {
|
||||
|
||||
fun value(option: NumberSetting) = settingsRepository.value(option)
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private val dynamicSettingGroups = settingsGroupProviders
|
||||
.map { it.settingGroups }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { groupFlows ->
|
||||
combine(groupFlows) { groups -> groups.toList().flatten() }
|
||||
}
|
||||
?: flowOf(emptyList())
|
||||
|
||||
val settingGroups: StateFlow<List<SettingGroup>> = dynamicSettingGroups
|
||||
.combine(flowOf(SettingsOptions.groups)) { dynamicGroups, staticGroups ->
|
||||
staticGroups + dynamicGroups
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = SettingsOptions.groups
|
||||
)
|
||||
|
||||
fun value(option: RangeSetting) = settingsRepository.value(option)
|
||||
|
||||
fun value(option: BooleanSetting) = settingsRepository.value(option)
|
||||
|
||||
fun value(option: StringSetting) = settingsRepository.value(option)
|
||||
|
||||
fun set(option: NumberSetting, value: Double) {
|
||||
fun <T> value(option: DropdownSetting<T>) = settingsRepository.value(option)
|
||||
|
||||
fun set(option: RangeSetting, value: Double) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.set(option, value)
|
||||
}
|
||||
@@ -41,6 +78,22 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> set(option: DropdownSetting<T>, value: T) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.set(option, value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onClick(option: VoidSetting) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
option.onClick()
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Setting action failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package hu.bbara.purefin.core.feature.update
|
||||
|
||||
import hu.bbara.purefin.core.settings.ReadOnlySetting
|
||||
import hu.bbara.purefin.core.settings.SettingGroup
|
||||
import hu.bbara.purefin.core.settings.SettingOption
|
||||
import hu.bbara.purefin.core.settings.SettingsGroupProvider
|
||||
import hu.bbara.purefin.core.settings.VoidSetting
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppUpdateController @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val appUpdateInstaller: AppUpdateInstaller,
|
||||
private val appVersionProvider: AppVersionProvider
|
||||
) : SettingsGroupProvider {
|
||||
private val _isCheckingForUpdates = MutableStateFlow(false)
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private val checkMutex = Mutex()
|
||||
private val installMutex = Mutex()
|
||||
private var checkedForUpdatesOnAppOpen = false
|
||||
|
||||
override val settingGroups: Flow<List<SettingGroup>> = availableUpdate
|
||||
.map { update ->
|
||||
val options = listOfNotNull<SettingOption<*>>(
|
||||
buildNumberSetting(),
|
||||
checkForUpdatesSetting(),
|
||||
update?.let { installUpdateSetting(it) }
|
||||
)
|
||||
|
||||
listOf(
|
||||
SettingGroup(
|
||||
title = "App",
|
||||
options = options
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(
|
||||
showUpToDateMessage: Boolean = true,
|
||||
showFailureMessage: Boolean = true
|
||||
) {
|
||||
if (!checkMutex.tryLock()) {
|
||||
return
|
||||
}
|
||||
|
||||
_isCheckingForUpdates.value = true
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
_availableUpdate.value = update
|
||||
if (update == null && showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (showFailureMessage) {
|
||||
_snackbarMessages.emit(e.message ?: "Update check failed")
|
||||
}
|
||||
} finally {
|
||||
_isCheckingForUpdates.value = false
|
||||
checkMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdatesOnAppOpen() {
|
||||
if (checkedForUpdatesOnAppOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
checkedForUpdatesOnAppOpen = true
|
||||
checkForUpdates(
|
||||
showUpToDateMessage = false,
|
||||
showFailureMessage = false
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun installAvailableUpdate() {
|
||||
val update = _availableUpdate.value ?: return
|
||||
if (!installMutex.tryLock()) {
|
||||
return
|
||||
}
|
||||
|
||||
_availableUpdate.value = null
|
||||
try {
|
||||
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update install failed")
|
||||
} finally {
|
||||
installMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun declineUpdate() {
|
||||
_availableUpdate.value = null
|
||||
}
|
||||
|
||||
private fun installUpdateSetting(update: AppUpdateInfo): VoidSetting {
|
||||
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
||||
return VoidSetting(
|
||||
key = INSTALL_APP_UPDATE_KEY,
|
||||
title = "Install Purefin $versionLabel",
|
||||
onClick = { installAvailableUpdate() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNumberSetting() = ReadOnlySetting(
|
||||
key = BUILD_NUMBER_KEY,
|
||||
title = "Build number",
|
||||
value = appVersionProvider.versionCode.toString()
|
||||
)
|
||||
|
||||
private fun checkForUpdatesSetting() = VoidSetting(
|
||||
key = CHECK_FOR_UPDATES_KEY,
|
||||
title = "Check for updates",
|
||||
onClick = { checkForUpdates() }
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val BUILD_NUMBER_KEY = "build_number"
|
||||
const val CHECK_FOR_UPDATES_KEY = "check_for_updates"
|
||||
const val INSTALL_APP_UPDATE_KEY = "install_app_update"
|
||||
}
|
||||
}
|
||||
@@ -3,76 +3,41 @@ package hu.bbara.purefin.core.feature.update
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppUpdateViewModel @Inject constructor(
|
||||
private val appUpdateRepository: AppUpdateRepository,
|
||||
private val appUpdateInstaller: AppUpdateInstaller
|
||||
private val appUpdateController: AppUpdateController
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isCheckingForUpdates = MutableStateFlow(false)
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
|
||||
val isCheckingForUpdates: StateFlow<Boolean> = appUpdateController.isCheckingForUpdates
|
||||
|
||||
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
|
||||
val availableUpdate: StateFlow<AppUpdateInfo?> = appUpdateController.availableUpdate
|
||||
|
||||
private val _snackbarMessages = MutableSharedFlow<String>()
|
||||
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
|
||||
|
||||
private var isInstallingUpdate = false
|
||||
val snackbarMessages: SharedFlow<String> = appUpdateController.snackbarMessages
|
||||
|
||||
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
|
||||
if (_isCheckingForUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_isCheckingForUpdates.value = true
|
||||
try {
|
||||
val update = appUpdateRepository.checkForUpdate()
|
||||
if (update == null) {
|
||||
if (showUpToDateMessage) {
|
||||
_snackbarMessages.emit("Purefin is up to date")
|
||||
}
|
||||
} else {
|
||||
_availableUpdate.value = update
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update check failed")
|
||||
} finally {
|
||||
_isCheckingForUpdates.value = false
|
||||
}
|
||||
appUpdateController.checkForUpdates(showUpToDateMessage = showUpToDateMessage)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForUpdatesOnAppOpen() {
|
||||
viewModelScope.launch {
|
||||
appUpdateController.checkForUpdatesOnAppOpen()
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptUpdate() {
|
||||
val update = _availableUpdate.value ?: return
|
||||
if (isInstallingUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
isInstallingUpdate = true
|
||||
_availableUpdate.value = null
|
||||
try {
|
||||
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
|
||||
} catch (e: Exception) {
|
||||
_snackbarMessages.emit(e.message ?: "Update install failed")
|
||||
} finally {
|
||||
isInstallingUpdate = false
|
||||
}
|
||||
appUpdateController.installAvailableUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
fun declineUpdate() {
|
||||
_availableUpdate.value = null
|
||||
appUpdateController.declineUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package hu.bbara.purefin.core.logging
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
object PurefinLogger {
|
||||
private val lock = Any()
|
||||
private var logStore: LogFileStore? = null
|
||||
private var initialized = false
|
||||
|
||||
fun initialize(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
val store = LogFileStore(File(context.filesDir, LOG_DIRECTORY))
|
||||
logStore = store
|
||||
val isDebuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||
if (isDebuggable) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
Timber.plant(FileLogTree(store))
|
||||
installUncaughtExceptionHandler(store)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilesForUpload(): List<UploadLogFile> {
|
||||
return logStore?.prepareFilesForUpload().orEmpty()
|
||||
}
|
||||
|
||||
fun deleteUploadedFile(uploadLogFile: UploadLogFile) {
|
||||
if (uploadLogFile.file.exists() && !uploadLogFile.file.delete()) {
|
||||
error("Uploaded log file could not be deleted: ${uploadLogFile.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun installUncaughtExceptionHandler(store: LogFileStore) {
|
||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
store.writeCrash(thread, throwable)
|
||||
} catch (logError: Exception) {
|
||||
Log.e("PurefinLogger", "Failed to write uncaught exception to log file", logError)
|
||||
} finally {
|
||||
if (previousHandler != null) {
|
||||
previousHandler.uncaughtException(thread, throwable)
|
||||
} else {
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val LOG_DIRECTORY = "purefin-logs"
|
||||
}
|
||||
|
||||
data class UploadLogFile(
|
||||
val name: String,
|
||||
val data: String,
|
||||
internal val file: File,
|
||||
)
|
||||
|
||||
private class FileLogTree(
|
||||
private val logStore: LogFileStore,
|
||||
) : Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
logStore.write(priority = priority, tag = tag, message = message, throwable = t)
|
||||
}
|
||||
}
|
||||
|
||||
private class LogFileStore(
|
||||
private val directory: File,
|
||||
) {
|
||||
private val activeFile = File(directory, ACTIVE_LOG_FILE)
|
||||
private val timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
|
||||
private val uploadNameFormatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")
|
||||
|
||||
@Synchronized
|
||||
fun write(priority: Int, tag: String?, message: String, throwable: Throwable?) {
|
||||
directory.mkdirs()
|
||||
val entry = buildEntry(priority, tag, message, throwable).toByteArray(Charsets.UTF_8)
|
||||
if (activeFile.exists() && activeFile.length() + entry.size > MAX_LOG_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(activeFile, true).use { output ->
|
||||
output.write(entry)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun writeCrash(thread: Thread, throwable: Throwable) {
|
||||
write(
|
||||
priority = Log.ERROR,
|
||||
tag = "UncaughtException",
|
||||
message = "Uncaught exception in ${thread.name}",
|
||||
throwable = throwable,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun flush() {
|
||||
directory.mkdirs()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun prepareFilesForUpload(): List<UploadLogFile> {
|
||||
directory.mkdirs()
|
||||
val candidates = uploadFiles() + rotatedFilesOldestFirst() + listOfNotNull(activeFile.takeIf { it.isLogFile() })
|
||||
if (candidates.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val preparedFiles = renameForUpload(candidates)
|
||||
return preparedFiles.map { file ->
|
||||
UploadLogFile(
|
||||
name = file.name,
|
||||
data = file.readText(Charsets.UTF_8),
|
||||
file = file,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEntry(priority: Int, tag: String?, message: String, throwable: Throwable?): String {
|
||||
val entry = buildString {
|
||||
append(timestampFormatter.format(LocalDateTime.now()))
|
||||
append(' ')
|
||||
append(priorityLabel(priority))
|
||||
append('/')
|
||||
append(tag ?: "Purefin")
|
||||
append(" [")
|
||||
append(Thread.currentThread().name)
|
||||
append("] ")
|
||||
append(message)
|
||||
append('\n')
|
||||
if (throwable != null) {
|
||||
append(Log.getStackTraceString(throwable))
|
||||
append('\n')
|
||||
}
|
||||
}
|
||||
return entry.truncateForLog()
|
||||
}
|
||||
|
||||
private fun priorityLabel(priority: Int): String = when (priority) {
|
||||
Log.VERBOSE -> "V"
|
||||
Log.DEBUG -> "D"
|
||||
Log.INFO -> "I"
|
||||
Log.WARN -> "W"
|
||||
Log.ERROR -> "E"
|
||||
Log.ASSERT -> "A"
|
||||
else -> priority.toString()
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
File(directory, "purefin.${MAX_LOG_FILES - 1}.log").delete()
|
||||
for (index in MAX_LOG_FILES - 2 downTo 1) {
|
||||
val file = File(directory, "purefin.$index.log")
|
||||
if (file.exists()) {
|
||||
file.renameTo(File(directory, "purefin.${index + 1}.log"))
|
||||
}
|
||||
}
|
||||
if (activeFile.exists()) {
|
||||
activeFile.renameTo(File(directory, "purefin.1.log"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameForUpload(files: List<File>): List<File> {
|
||||
val uploadTimestamp = uploadNameFormatter.format(LocalDateTime.now())
|
||||
val tempFiles = files.mapIndexed { index, file ->
|
||||
val tempFile = File(directory, ".upload-$uploadTimestamp-$index.tmp")
|
||||
tempFile.delete()
|
||||
if (!file.renameTo(tempFile)) {
|
||||
error("Log file could not be prepared for upload: ${file.name}")
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
return tempFiles.mapIndexed { index, file ->
|
||||
val uploadName = "${uploadTimestamp}_${(index + 1).toString().padStart(3, '0')}.log"
|
||||
val uploadFile = File(directory, uploadName)
|
||||
uploadFile.delete()
|
||||
if (!file.renameTo(uploadFile)) {
|
||||
error("Log file could not be named for upload: $uploadName")
|
||||
}
|
||||
uploadFile
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.truncateForLog(): String {
|
||||
if (length <= MAX_LOG_ENTRY_CHARS) {
|
||||
return this
|
||||
}
|
||||
val omittedChars = length - MAX_LOG_ENTRY_CHARS
|
||||
return take(MAX_LOG_ENTRY_CHARS) + "\n... truncated $omittedChars more chars\n"
|
||||
}
|
||||
|
||||
private fun uploadFiles(): List<File> {
|
||||
return directory.listFiles()
|
||||
.orEmpty()
|
||||
.filter { it.isFile && UPLOAD_LOG_FILE_REGEX.matches(it.name) }
|
||||
.sortedWith(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
}
|
||||
|
||||
private fun rotatedFilesOldestFirst(): List<File> {
|
||||
return (MAX_LOG_FILES - 1 downTo 1)
|
||||
.map { File(directory, "purefin.$it.log") }
|
||||
.filter { it.isLogFile() }
|
||||
}
|
||||
|
||||
private fun File.isLogFile(): Boolean {
|
||||
return isFile && length() > 0L
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ACTIVE_LOG_FILE = "purefin.log"
|
||||
const val MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||
const val MAX_LOG_ENTRY_CHARS = 4_000
|
||||
const val MAX_LOG_FILES = 4
|
||||
val UPLOAD_LOG_FILE_REGEX = Regex("""\d{8}_\d{6}(?:_\d{3})?\.log""")
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,5 @@ data class EpisodeDto(
|
||||
val seasonId: UUID,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val seriesId: UUID,
|
||||
val offline: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -7,4 +7,5 @@ import kotlinx.serialization.Serializable
|
||||
data class MovieDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
val offline: Boolean = false,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user