Compare commits

...

67 Commits

Author SHA1 Message Date
ef56db5d16 feat: redesign settings screen 2026-05-23 12:55:37 +00:00
Barnabás Balogh
d81c382ba5 Merge pull request #3 from bbara04/feat/debug-build-icon-update-url
feat: add red debug icon and separate debug update URL
2026-05-23 11:56:08 +02:00
6e90a9e225 feat(build): update debug and release update URLs in README and build.gradle.kts 2026-05-23 11:52:04 +02:00
8d73974add feat(build): add separate versionCode for debug build type
Renamed purefinVersionCode to purefinReleaseVersionCode and added purefinDebugVersionCode to allow independent version tracking for debug and release builds.
2026-05-23 09:31:47 +00:00
Purefin Dev
2e4eb2fb30 feat: add red debug icon and separate debug update URL
- Replace hardcoded vector fill color with @color/ic_launcher_foreground
- Override foreground to #E53935 (red) for debug builds
- Use manifest placeholders for build-type specific update URLs
- Update release URL to https://apks.t.bbara.hu/apks/purefin-app/update.json
- Add APK repository upload guide to README
2026-05-23 08:50:25 +00:00
4c15474ad9 feat(build): add debug build type with applicationId and version name suffixes 2026-05-22 21:17:20 +02:00
bbec9c1e06 chore(build): increment app versionCode to 1000018 2026-05-22 21:17:20 +02:00
Barnabás Balogh
e82e370e48 Merge pull request #2 from bbara04/feat/brightness-auto-mode
feat/brightness-auto-mode
2026-05-22 18:52:44 +02:00
41ae5f939d Merge remote-tracking branch 'origin/feat/brightness-auto-mode' into feat/brightness-auto-mode
# Conflicts:
#	app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt
2026-05-22 18:50:45 +02:00
Purefin Dev
d14eec2346 feat(player): add adaptive brightness mode support
Extend the brightness slider to allow values below 0% which triggers
adaptive/auto brightness mode (screenBrightness = -1.0f). The brightness
indicator shows 'Auto' with an empty bar when in adaptive mode.

- Widen gesture range from [0,1] to [-0.1,1] so a small flick below 0 enters auto mode
- readCurrentBrightness returns -0.1f instead of 0.5f when system auto is active
- applyBrightness writes -1f to LayoutParams when value < 0
- PlayerAdjustmentIndicator displays 'Auto' in tertiary color for negative values
2026-05-22 16:46:22 +00:00
Purefin Dev
70a587b743 feat(player): add adaptive brightness mode support
Extend the brightness slider to allow values below 0% which triggers
adaptive/auto brightness mode (screenBrightness = -1.0f). The brightness
indicator shows 'Auto' with an empty bar when in adaptive mode.

- Widen gesture range from [0,1] to [-1,1] so dragging below 0 enters auto mode
- readCurrentBrightness returns -1f instead of 0.5f when system auto is active
- applyBrightness writes -1f to LayoutParams when value < 0
- PlayerAdjustmentIndicator displays 'Auto' in tertiary color for negative values
2026-05-22 16:41:58 +00:00
56da98a90b refactor(settings): remove Settings text 2026-05-22 13:42:42 +02:00
Barnabás Balogh
5dabc8bbf0 Merge pull request #1 from bbara04/todos
Refactor: replace collectAsState with lifecycle-aware variant & add per-library visibility toggles
2026-05-22 13:34:38 +02:00
Purefin Dev
8da759257c refactor: replace collectAsState with lifecycle-aware collectAsStateWithLifecycle
Migrate all Flow state collection to collectAsStateWithLifecycle for proper lifecycle awareness, ensuring collection stops when the composable is not in the STARTED state.

- Added lifecycle-runtime-compose dependency to app and app-tv modules
- Replaced all collectAsState() calls with collectAsStateWithLifecycle()
- Updated initial parameter to initialValue for the lifecycle-aware variant
2026-05-21 19:47:58 +00:00
Purefin Dev
645e28ab30 feat(settings): add per-library visibility toggles for home screen
Add a Home Screen Libraries setting group with per-library toggles to control which libraries appear on the home screen. Libraries toggled off are hidden from both phone and TV home screens but remain accessible via the Libraries tab and navigation drawer.
2026-05-21 19:38:15 +00:00
1274ab25f9 feat(app): expose test tags for Appium 2026-05-21 10:10:08 +02:00
56a2b42434 feat(network): enhance connectivity monitoring with request reporting 2026-05-20 17:07:32 +02:00
069f29c1c8 chore(build): increment app-tv versionCode to 2000009 2026-05-16 17:42:20 +02:00
f7b266ec8c fix(docs): remove unnecessary blank line in AGENTS.md 2026-05-16 13:49:39 +02:00
fcf7c435be chore(gitignore): add .opencode to ignore list 2026-05-16 13:42:48 +02:00
aa884994c2 chore(build): increment app versionCode to 1000017 2026-05-14 23:09:05 +02:00
7ae0c2eb27 feat(api): increase item limit for library queries from 10 to 15 2026-05-14 21:45:06 +02:00
b7186be46a feat(player): enhance video aspect ratio handling and resize mode logic 2026-05-14 21:44:50 +02:00
deacb89adb chore(build): increment app versionCode to 1000016 2026-05-14 18:30:16 +02:00
e09b63f47e refactor(logging): standardize logRequest function naming and remove redundant logging 2026-05-14 18:29:58 +02:00
1fc2c8cf05 feat(logging): improve uncaught exception handling with dedicated logging method 2026-05-14 18:24:56 +02:00
c70657e627 feat(logging): enhance log entry handling with truncation and improve upload file naming 2026-05-14 18:23:21 +02:00
063f2fd5ca chore(build): increment app versionCode to 1000015 2026-05-14 18:07:48 +02:00
4b87f93dee Add Timber file logging and Jellyfin log upload 2026-05-14 18:06:54 +02:00
f95fbf72b3 chore(build): increment app versionCode to 1000014 2026-05-14 14:38:40 +02:00
6cff6be238 feat: enhance media playback handling with offline support and download metadata integration 2026-05-14 14:38:24 +02:00
a0a51ef436 feat: add smart download management with enable and delete options 2026-05-14 14:09:35 +02:00
f60aba5a72 feat: update media selection logic to handle offline state in view models and DTOs 2026-05-14 12:56:48 +02:00
83730188eb feat: add online status handling to navigation and UI components 2026-05-14 12:39:20 +02:00
a92c10a07c chore(build): increment app versionCode to 1000013 2026-05-13 23:07:12 +02:00
657049edc9 chore(build): increment app-tv versionCode to 2000008 2026-05-13 23:05:34 +02:00
70f086b5a1 feat: enhance player UI with hidden seek timeline and time display improvements 2026-05-13 23:05:10 +02:00
cc643ae34f feat: add preferenceMediaId to PlayableMedia and update related logic to make more prone to mistakes 2026-05-13 22:53:27 +02:00
9fb3672c19 chore(build): update versionCode to use properties from gradle.properties 2026-05-13 22:37:25 +02:00
6a5f2fd593 chore(build): increment app-tv versionCode to 2000007 2026-05-13 22:23:18 +02:00
8950a37341 feat: update app to check for updates on app open 2026-05-13 22:22:53 +02:00
7ec9a0f41f feat: implement logout functionality and settings provider 2026-05-13 22:17:50 +02:00
d3b66d4fc5 feat: redesign login screen on both app and app-tv 2026-05-13 21:59:23 +02:00
e69a3a6d4a feat: redesign login screen on both app and app-tv 2026-05-13 21:16:48 +02:00
dbcabd0848 refactor: navigation drawer to use state management and auto-close on item selection 2026-05-13 21:00:22 +02:00
beae723372 feat: add ReadOnlySetting and corresponding UI component for displaying read-only settings 2026-05-13 19:31:58 +02:00
147fabd895 chore(build): increment app versionCode to 1000012 2026-05-13 19:23:23 +02:00
56aa16755d refactor: simplify layout in EpisodeComponents and remove unused modifiers 2026-05-13 19:19:51 +02:00
f5d476b28e feat: increment version code to 2000006 2026-05-13 19:14:07 +02:00
2f6d7e77c3 refactor: use common DefaultTopBar component in all components 2026-05-13 17:52:30 +02:00
fbb19e6970 refactor: extract common MediaComponents screen layout skeleton into its own component. 2026-05-13 17:33:46 +02:00
58d418ff65 feat: increment version code to 1000011 for app updates 2026-05-13 17:04:58 +02:00
3ee4b558f5 Make media detail flows reactive 2026-05-13 16:43:42 +02:00
406d0f4ed8 feat(tvseries): improve focus management for season and episode selection 2026-05-09 09:03:07 +02:00
4e9d8c0b3b feat(player): enhance UI responsiveness with animated color states for buttons and track selection 2026-05-09 08:49:49 +02:00
4e322e7723 feat(player): pause playback on lifecycle stop event for improved resource management 2026-05-09 08:29:27 +02:00
2c0d689767 feat: increment version codes for app updates 2026-05-08 22:36:28 +02:00
26a134869e feat(settings): update settings items to handle null values for improved stability 2026-05-08 22:35:36 +02:00
80f5454afb feat(settings): add app update settings action 2026-05-08 22:29:07 +02:00
60d2a1eb2a feat: trigger refresh on home navigation in TvAppScreen for improved data consistency 2026-05-08 20:26:26 +02:00
0554f328c6 feat: trigger refresh on home navigation in TvAppScreen for improved data consistency 2026-05-08 20:20:07 +02:00
af2e8b0dc9 feat: enhance settings screen with dropdown and void setting options for improved user experience 2026-05-08 20:19:05 +02:00
e05041fd14 feat: add focus restoration to TabRow in TvSeriesComponents for improved accessibility 2026-05-08 19:46:46 +02:00
9313cded53 feat: enhance TvSeriesScreen to support focused season and episode loading 2026-05-08 19:44:49 +02:00
4788eec102 feat: add SeriesIdLookUpUseCase for episode to series ID mapping and update track preference saving logic 2026-05-08 19:30:44 +02:00
5444d3c8b1 feat: refactor logging in JellyfinApiClient to improve request tracking and error handling 2026-05-08 18:53:18 +02:00
9d7c166bc8 feat: refactor SeriesScreen and ViewModel for improved season episode loading and download handling 2026-05-08 18:47:15 +02:00
131 changed files with 4994 additions and 1858 deletions

1
.gitignore vendored
View File

@@ -18,4 +18,5 @@
local.properties
.codex/
openspec
.opencode
*.patch

View File

@@ -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 Googles 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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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 ->

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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
)
}

View File

@@ -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()
}
)
}

View File

@@ -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
)
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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

View File

@@ -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
)
}
}
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_foreground">#E53935</color>
</resources>

View File

@@ -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"

View File

@@ -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 }
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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
)
)
)
)
}
}

View File

@@ -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"

View File

@@ -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()
)

View File

@@ -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
)
}

View File

@@ -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-"

View File

@@ -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-"

View File

@@ -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 = {}
)
}

View File

@@ -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"

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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,11 +146,20 @@ fun HomeContent(
item(key = "empty-state") {
HomeEmptyState(
onRefresh = onRefresh,
onBrowseLibrariesClick = onBrowseLibrariesClick
onBrowseLibrariesClick = onBrowseLibrariesClick,
modifier = Modifier.testTag(HomeEmptyStateTag)
)
}
}
}
}
}
}
}
internal const val HomeContentTag = "home-content"
internal const val HomeContentViewportTag = "home-content-viewport"
internal const val HomeFeaturedSectionTag = "home-section-featured"
internal const val HomeContinueWatchingSectionTag = "home-section-continue-watching"
internal const val HomeNextUpSectionTag = "home-section-next-up"
internal const val HomeLibrarySectionTagPrefix = "home-section-library-"
internal const val HomeEmptyStateTag = "home-empty-state"

View File

@@ -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",

View File

@@ -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-"

View File

@@ -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-"

View File

@@ -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-"

View File

@@ -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-"

View File

@@ -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(

View File

@@ -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-"

View File

@@ -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
)
}

View File

@@ -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-"

View File

@@ -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-"

View File

@@ -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"

View File

@@ -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
)
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
)
}

View File

@@ -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()

View File

@@ -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)
}
}
)

View File

@@ -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_"

View File

@@ -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,

View File

@@ -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,
)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
)
}
}

View File

@@ -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)
}
)
}
}
}
}

View File

@@ -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()
)
}
}
}

View File

@@ -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
)
}

View File

@@ -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)
)
}
}

View File

@@ -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)
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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))
}
}

View File

@@ -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
)

View File

@@ -41,5 +41,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.datastore)
implementation(libs.okhttp)
implementation(libs.timber)
testImplementation(libs.junit)
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -4,4 +4,8 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
fun reportRequestSucceeded()
fun reportRequestFailed(error: Throwable)
}

View File

@@ -16,6 +16,7 @@ interface UserSessionRepository {
val isLoggedIn: Flow<Boolean>
suspend fun setLoggedIn(isLoggedIn: Boolean)
suspend fun logout()
val isOfflineMode: Flow<Boolean>
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
))
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
)
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -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""")
}
}

View File

@@ -11,4 +11,5 @@ data class EpisodeDto(
val seasonId: UUID,
@Serializable(with = UuidSerializer::class)
val seriesId: UUID,
val offline: Boolean = false,
)

View File

@@ -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