diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 4bad733..2b51607 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -2,14 +2,66 @@ package io.narl.protonstream.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import uniffi.pstr_android.EpisodeRecord +/** + * A tab's page: its own name in an app bar, over whatever the page holds. + * + * One bar for the whole app said "proton-stream" on every page, which is a band + * of height spent naming the app the viewer is already in. The bar draws under + * the status bar itself, which is why the shell hands pages no top padding; + * [content] gets the rest of [padding] (the navigation bar and the mini + * transport) to apply as it scrolls. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TabPage( + title: String, + padding: PaddingValues, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + // Pinned: the bar stays, and takes the container tint once the page has + // scrolled under it, so the edge between the two is still visible. + val scroll = TopAppBarDefaults.pinnedScrollBehavior() + Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { + TopAppBar( + title = { Text(title) }, + navigationIcon = navigationIcon, + actions = actions, + colors = pageBarColors(), + scrollBehavior = scroll, + ) + Box(Modifier.weight(1f)) { content(padding) } + } +} + +/** + * The page's own background until something scrolls under the bar, then the + * container tint. A bar in `surface` from the start read as a band across the + * top of a page drawn in `background`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, +) + @Composable internal fun EmptyState(title: String, body: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt index 415eabf..1310f36 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -65,72 +65,74 @@ internal fun DownloadsScreen( .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" } .toSortedMap() } - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), - contentPadding = PaddingValues(bottom = 16.dp), - ) { - item { - Text("Offline downloads", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) - } - if (retained.isEmpty() && state.offline.isEmpty()) { - item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } - } - // Under the show they belong to, as on desktop: a saved season is - // fourteen rows, and fourteen rows of "Episode 3" name nothing. - groups.forEach { (show, files) -> - item(key = "group/$show") { - Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp)) - Text( - "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " + - formatBytes(files.sumOf { it.size }), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 8.dp), - ) + TabPage("Downloads", padding) { body -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(body).padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + item { + Text("Offline downloads", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) } - items(files, key = { "${it.shareId}/${it.linkId}" }) { file -> - val episode = file.episode - Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) - Text(formatBytes(file.size)) - } - TonalButton(onClick = { onRemove(file) }) { - Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Delete") + if (retained.isEmpty() && state.offline.isEmpty()) { + item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } + } + // Under the show they belong to, as on desktop: a saved season is + // fourteen rows, and fourteen rows of "Episode 3" name nothing. + groups.forEach { (show, files) -> + item(key = "group/$show") { + Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp)) + Text( + "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " + + formatBytes(files.sumOf { it.size }), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + items(files, key = { "${it.shareId}/${it.linkId}" }) { file -> + val episode = file.episode + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) + Text(formatBytes(file.size)) + } + TonalButton(onClick = { onRemove(file) }) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Delete") + } } } } } - } - items(retained, key = { "${it.shareId}/${it.linkId}" }) { download -> - val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f - Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Column(Modifier.padding(16.dp)) { - Text(download.label, fontWeight = FontWeight.SemiBold) - Text(download.status.replaceFirstChar { it.uppercase() }) - if (download.total > 0L) { - AccentProgress( - progress = { progress }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall) - } - download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 8.dp), - ) { - if (download.status == RetainedDownload.STATUS_RUNNING || - download.status == RetainedDownload.STATUS_QUEUED + items(retained, key = { "${it.shareId}/${it.linkId}" }) { download -> + val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Column(Modifier.padding(16.dp)) { + Text(download.label, fontWeight = FontWeight.SemiBold) + Text(download.status.replaceFirstChar { it.uppercase() }) + if (download.total > 0L) { + AccentProgress( + progress = { progress }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall) + } + download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), ) { - TonalButton(onClick = { onPause(download) }) { Text("Pause") } - } else { - TonalButton(onClick = { onResume(download) }) { Text("Resume") } + if (download.status == RetainedDownload.STATUS_RUNNING || + download.status == RetainedDownload.STATUS_QUEUED + ) { + TonalButton(onClick = { onPause(download) }) { Text("Pause") } + } else { + TonalButton(onClick = { onResume(download) }) { Text("Resume") } + } + QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") } } - QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") } } } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt index 9c98c6b..07b58d6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt @@ -80,28 +80,32 @@ internal fun HistoryScreen( history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() } } if (days.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding)) { - EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.") + TabPage("History", padding) { body -> + Box(Modifier.fillMaxSize().padding(body)) { + EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.") + } } return } - LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) { - days.forEach { (day, played) -> - item(key = "day/$day") { - Text( - dayHeading(day, today), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp), - ) - } - items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry -> - HistoryRow( - entry, - onPlay = { onPlay(entry.title, entry.index) }, - onTitle = { onTitle(entry.title) }, - onRemove = { onRemove(entry.title, entry.episode) }, - ) + TabPage("History", padding) { body -> + LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) { + days.forEach { (day, played) -> + item(key = "day/$day") { + Text( + dayHeading(day, today), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp), + ) + } + items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry -> + HistoryRow( + entry, + onPlay = { onPlay(entry.title, entry.index) }, + onTitle = { onTitle(entry.title) }, + onRemove = { onRemove(entry.title, entry.episode) }, + ) + } } } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index e3b432d..d43cd13 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -1,6 +1,19 @@ package io.narl.protonstream.ui import androidx.compose.foundation.background +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.input.ImeAction import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.ui.graphics.Brush @@ -105,39 +118,49 @@ internal fun LibraryScreen( ?.let { Resumable(title, it.value, it.index) } }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) } - Column(Modifier.fillMaxSize().padding(padding)) { - // A filled pill rather than an outlined form field: search is how the - // page is navigated, not a value being entered. - TextField( - value = state.query, - onValueChange = onSearch, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - singleLine = true, - shape = CircleShape, - placeholder = { Text("Search library") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - trailingIcon = { - if (state.query.isNotEmpty()) { - IconButton(onClick = { onSearch("") }) { - Icon(Icons.Default.Close, contentDescription = "Clear search") + // Search is an icon until it is wanted: a permanent field above the + // library spent a band of every visit on something used in few of them. + var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) } + val closeSearch = { + searching = false + onSearch("") + } + BackHandler(enabled = searching, onBack = closeSearch) + // The large title folds into the bar as the grid scrolls, and comes back + // only at the top: the page says what it is on arrival, then gets out of + // the way. + val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { + if (searching) { + SearchBar(state.query, onSearch, closeSearch) + } else { + LargeTopAppBar( + title = { Text("Library") }, + actions = { + IconButton(onClick = { searching = true }) { + Icon(Icons.Default.Search, contentDescription = "Search library") } - } - }, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - ) - // Pull to refresh, the gesture every list on a phone answers to; the - // icon in the bar stays for a viewer who does not know it. + // Pull to refresh is the gesture; the icon stays for a + // viewer who does not know it. + IconButton(onClick = onRefresh, enabled = !state.refreshing) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + colors = pageBarColors(), + scrollBehavior = scroll, + ) + } PullToRefreshBox( isRefreshing = state.refreshing, onRefresh = onRefresh, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.weight(1f).padding(padding), ) { when { state.loading -> LibrarySkeleton() + state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState( + "Nothing matches", + "No title in the library is called “${state.query.trim()}”.", + ) state.titles.isEmpty() -> EmptyState( "Your library is empty", "Add a Proton Drive public link under Shares, then refresh.", @@ -183,6 +206,50 @@ internal fun LibraryScreen( } } +/** + * The bar while searching: back closes the search and clears it, and the field + * takes the keyboard as soon as it opens, since opening it is asking to type. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + TopAppBar( + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") + } + }, + title = { + // A filled pill rather than an outlined form field: search is how + // the page is navigated, not a value being entered. + TextField( + value = query, + onValueChange = onSearch, + modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus), + singleLine = true, + shape = CircleShape, + placeholder = { Text("Search library") }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onSearch("") }) { + Icon(Icons.Default.Close, contentDescription = "Clear search") + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + ) + }, + colors = pageBarColors(), + ) +} + /** * A long press's menu, from the bottom of the screen where the thumb already * is. Each row closes it before doing its thing. @@ -279,7 +346,7 @@ private fun LibraryGrid( } } item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { - Text("Library", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) } } items(state.titles, key = { it.key }) { title -> diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 05b89a4..d7a1ee3 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,16 +16,18 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.plus import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material.icons.Icons @@ -35,7 +36,6 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -162,23 +162,10 @@ fun ProtonStreamApp( ) { Scaffold( snackbarHost = { SnackbarHost(snackbars) }, - topBar = { - TopAppBar( - title = { Text(selectedTitle?.name ?: "proton-stream") }, - actions = { - if (destination == Destination.Library) { - if (state.refreshing) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(Modifier.width(16.dp)) - } else { - IconButton(onClick = model::refresh) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh") - } - } - } - }, - ) - }, + // Each page draws its own bar under the status bar, and the title + // page draws its backdrop there, so the top inset is theirs. + contentWindowInsets = ScaffoldDefaults.contentWindowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom), ) { padding -> // Back from a secondary tab returns to the library rather than // leaving the app; the library's own back is the system's, which is diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt index 0e4e707..330a09d 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -106,153 +106,155 @@ internal fun SettingsScreen( // Rows with one line of explanation each, in groups, rather than a toggle // followed by a paragraph: the page read as a document to get through // before reaching the switch that was wanted. - Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) { - SettingsGroup("Playback") - prefs?.let { current -> - SettingSwitch( - "Autoplay next episode", - "Starts the next episode when the credits begin", - current.autoplayNext, - ) { on -> update { it.copy(autoplayNext = on) } } - // Off is the safer default: a mis-named chapter then costs a tap on - // Skip rather than a scene. - SettingSwitch( - "Skip openings and endings", - "Uses the release's chapters; off shows a Skip button instead", - current.autoSkip, - ) { on -> update { it.copy(autoSkip = on) } } - SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on -> - update { it.copy(subtitles = on) } + TabPage("Settings", padding) { body -> + Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) { + SettingsGroup("Playback") + prefs?.let { current -> + SettingSwitch( + "Autoplay next episode", + "Starts the next episode when the credits begin", + current.autoplayNext, + ) { on -> update { it.copy(autoplayNext = on) } } + // Off is the safer default: a mis-named chapter then costs a tap on + // Skip rather than a scene. + SettingSwitch( + "Skip openings and endings", + "Uses the release's chapters; off shows a Skip button instead", + current.autoSkip, + ) { on -> update { it.copy(autoSkip = on) } } + SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on -> + update { it.copy(subtitles = on) } + } + // Language tags, not a picker: which languages exist is a property + // of each file, and a list built from one episode is wrong for the + // next. A show that has been given its own choice keeps it. + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag -> + update { it.copy(audioLanguage = tag) } + } + LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag -> + update { it.copy(subtitleLanguage = tag) } + } + } + Text( + "Tags as they appear in the file, such as jpn or eng", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) } - // Language tags, not a picker: which languages exist is a property - // of each file, and a list built from one episode is wrong for the - // next. A show that has been given its own choice keeps it. - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + SettingSwitch( + "Hardware decoding", + "Turn off if video shows green or torn frames", + hardwareDecoding, ) { - LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag -> - update { it.copy(audioLanguage = tag) } - } - LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag -> - update { it.copy(subtitleLanguage = tag) } - } + hardwareDecoding = it + settings.hardwareDecoding = it + } + SettingSwitch( + "Background audio", + "Keeps playing with the app closed, from the notification", + backgroundAudio, + ) { + backgroundAudio = it + settings.backgroundAudio = it } - Text( - "Tags as they appear in the file, such as jpn or eng", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - SettingSwitch( - "Hardware decoding", - "Turn off if video shows green or torn frames", - hardwareDecoding, - ) { - hardwareDecoding = it - settings.hardwareDecoding = it - } - SettingSwitch( - "Background audio", - "Keeps playing with the app closed, from the notification", - backgroundAudio, - ) { - backgroundAudio = it - settings.backgroundAudio = it - } - SettingsGroup("Downloads and storage") - SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) { - wifiOnly = it - settings.wifiOnly = it - // Constraints are baked in at enqueue time, so a queue that already - // exists keeps the policy it was queued under until it is re-issued. - DownloadCoordinator.applyNetworkPolicy(context) - } - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text("Streaming cache") - Text( - "${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AccentProgress( - progress = { - (state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble()) - .toFloat().coerceIn(0f, 1f) - }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) - // What has been watched is kept up to this much, so going back over - // a scene costs no download. A lower choice frees the difference at - // once. Wrapping, so five chips survive a narrow phone at a large - // font scale. - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib -> - FilterChip( - selected = gib == cacheBudgetGib, - onClick = { - cacheBudgetGib = gib - settings.cacheBudgetGib = gib - scope.launch(Dispatchers.IO) { - runCatching { - NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib)) + SettingsGroup("Downloads and storage") + SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) { + wifiOnly = it + settings.wifiOnly = it + // Constraints are baked in at enqueue time, so a queue that already + // exists keeps the policy it was queued under until it is re-issued. + DownloadCoordinator.applyNetworkPolicy(context) + } + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Streaming cache") + Text( + "${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AccentProgress( + progress = { + (state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble()) + .toFloat().coerceIn(0f, 1f) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) + // What has been watched is kept up to this much, so going back over + // a scene costs no download. A lower choice frees the difference at + // once. Wrapping, so five chips survive a narrow phone at a large + // font scale. + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib -> + FilterChip( + selected = gib == cacheBudgetGib, + onClick = { + cacheBudgetGib = gib + settings.cacheBudgetGib = gib + scope.launch(Dispatchers.IO) { + runCatching { + NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib)) + } } - } - }, - label = { Text("$gib GiB") }, - ) + }, + label = { Text("$gib GiB") }, + ) + } } } - } - // The cache is rebuildable, so it goes without asking. Offline episodes - // are a choice the viewer made, so that one asks. - SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache) - SettingAction( - "Delete all offline episodes", - "${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") + - " · ${formatBytes(state.storage.offlineBytes)}" + - ( - state.storage.partialBytes.takeIf { it > 0uL } - ?.let { " · ${formatBytes(it)} unfinished" } - .orEmpty() - ), - enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL, - onClick = { confirmDelete = true }, - ) + // The cache is rebuildable, so it goes without asking. Offline episodes + // are a choice the viewer made, so that one asks. + SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache) + SettingAction( + "Delete all offline episodes", + "${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") + + " · ${formatBytes(state.storage.offlineBytes)}" + + ( + state.storage.partialBytes.takeIf { it > 0uL } + ?.let { " · ${formatBytes(it)} unfinished" } + .orEmpty() + ), + enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL, + onClick = { confirmDelete = true }, + ) - SettingsGroup("Appearance") - Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() } + SettingsGroup("Appearance") + Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() } - SettingsGroup("Metadata") - SettingAction( - "Metadata enrichment", - if (state.metadataSettings.enabled) { - "On · ${state.metadataSettings.provider.displayName()} · sends title names to it" - } else { - "Off, the privacy default" - }, - onClick = { showMetadata = true }, - ) - // Every title looked up again, matched ones included: the way out of a - // library the provider answered wrong, which otherwise stays wrong for - // as long as the match is remembered. - SettingAction( - "Match everything again", - "Looks every title up again, hand-picked matches excepted", - enabled = state.metadataSettings.enabled && !state.refreshing, - onClick = onMatchAgain, - ) + SettingsGroup("Metadata") + SettingAction( + "Metadata enrichment", + if (state.metadataSettings.enabled) { + "On · ${state.metadataSettings.provider.displayName()} · sends title names to it" + } else { + "Off, the privacy default" + }, + onClick = { showMetadata = true }, + ) + // Every title looked up again, matched ones included: the way out of a + // library the provider answered wrong, which otherwise stays wrong for + // as long as the match is remembered. + SettingAction( + "Match everything again", + "Looks every title up again, hand-picked matches excepted", + enabled = state.metadataSettings.enabled && !state.refreshing, + onClick = onMatchAgain, + ) - SettingsGroup("About") - SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") { - legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt") + SettingsGroup("About") + SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") { + legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt") + } + SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") { + legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md") + } + Spacer(Modifier.height(24.dp)) } - SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") { - legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md") - } - Spacer(Modifier.height(24.dp)) } if (confirmDelete) { AlertDialog( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index 9933f08..481a702 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -60,38 +60,33 @@ internal fun SharesScreen( var showAdd by remember { mutableStateOf(false) } var repairing by remember { mutableStateOf(null) } var removing by remember { mutableStateOf(null) } - Box(Modifier.fillMaxSize().padding(padding)) { - if (shares.isEmpty()) { - EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") - } else { - LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { - item(key = "heading") { - Text( - "Proton Drive public links", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - items(shares, key = { it.id }) { share -> - ShareRow( - share = share, - onRefresh = { onRefresh(share.id) }, - onRepair = { repairing = share }, - onRemove = { removing = share }, - ) + TabPage("Shares", padding) { body -> + Box(Modifier.fillMaxSize().padding(body)) { + if (shares.isEmpty()) { + EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") + } else { + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { + items(shares, key = { it.id }) { share -> + ShareRow( + share = share, + onRefresh = { onRefresh(share.id) }, + onRepair = { repairing = share }, + onRemove = { removing = share }, + ) + } } } + // Where a thumb is, and the one thing this page is for. The share + // sheet and a tapped link land in the same form. + ExtendedFloatingActionButton( + onClick = { showAdd = true }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Add share") }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) } - // Where a thumb is, and the one thing this page is for. The share - // sheet and a tapped link land in the same form. - ExtendedFloatingActionButton( - onClick = { showAdd = true }, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text("Add share") }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), - ) } if (showAdd || incomingLink != null) { AddShareDialog( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index f98618b..e79676a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -14,6 +14,7 @@ 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.statusBarsPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.aspectRatio @@ -144,6 +145,7 @@ internal fun TitleScreen( IconButton( onClick = onBack, modifier = Modifier + .statusBarsPadding() .padding(8.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.4f)), diff --git a/android/app/src/test/screenshots/downloads-empty.png b/android/app/src/test/screenshots/downloads-empty.png index c83e719..27fba5b 100644 Binary files a/android/app/src/test/screenshots/downloads-empty.png and b/android/app/src/test/screenshots/downloads-empty.png differ diff --git a/android/app/src/test/screenshots/history-phone.png b/android/app/src/test/screenshots/history-phone.png index 90c4ea8..41f5e3a 100644 Binary files a/android/app/src/test/screenshots/history-phone.png and b/android/app/src/test/screenshots/history-phone.png differ diff --git a/android/app/src/test/screenshots/library-empty.png b/android/app/src/test/screenshots/library-empty.png index c46d27e..6b20bbe 100644 Binary files a/android/app/src/test/screenshots/library-empty.png and b/android/app/src/test/screenshots/library-empty.png differ diff --git a/android/app/src/test/screenshots/library-loading.png b/android/app/src/test/screenshots/library-loading.png index c96bf5c..c0e897e 100644 Binary files a/android/app/src/test/screenshots/library-loading.png and b/android/app/src/test/screenshots/library-loading.png differ diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png index ef85633..50461f6 100644 Binary files a/android/app/src/test/screenshots/library-phone-large-font.png and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png index a800890..868929e 100644 Binary files a/android/app/src/test/screenshots/library-phone.png and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png index c802f33..86ba5a4 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/android/app/src/test/screenshots/settings-phone.png b/android/app/src/test/screenshots/settings-phone.png index b8f8a70..2b4d28b 100644 Binary files a/android/app/src/test/screenshots/settings-phone.png and b/android/app/src/test/screenshots/settings-phone.png differ diff --git a/android/app/src/test/screenshots/shares-phone.png b/android/app/src/test/screenshots/shares-phone.png index b29de46..be4d525 100644 Binary files a/android/app/src/test/screenshots/shares-phone.png and b/android/app/src/test/screenshots/shares-phone.png differ