diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LanguagePicker.kt b/android/app/src/main/java/io/narl/protonstream/ui/LanguagePicker.kt new file mode 100644 index 0000000..6bf851b --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/LanguagePicker.kt @@ -0,0 +1,183 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +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.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.TonalButton + +/** + * The languages offered by name, as the three-letter tag a Matroska file + * carries. + * + * The same short list the desktop names tracks from (`pstr-player`'s + * `language_name`): what the shares this app is pointed at actually contain. + * mpv treats the two- and three-letter forms of a language as one, so the tag + * chosen here also matches a file that says "ja". Anything else is still one + * typed tag away. + */ +internal val LANGUAGES: List> = listOf( + "jpn" to "Japanese", + "eng" to "English", + "ger" to "German", + "fre" to "French", + "spa" to "Spanish", + "ita" to "Italian", + "por" to "Portuguese", + "dut" to "Dutch", + "rus" to "Russian", + "pol" to "Polish", + "swe" to "Swedish", + "dan" to "Danish", + "nor" to "Norwegian", + "fin" to "Finnish", + "cze" to "Czech", + "hun" to "Hungarian", + "tur" to "Turkish", + "ara" to "Arabic", + "heb" to "Hebrew", + "hin" to "Hindi", + "kor" to "Korean", + "chi" to "Chinese", + "tha" to "Thai", + "vie" to "Vietnamese", + "ukr" to "Ukrainian", + "rum" to "Romanian", + "gre" to "Greek", + "ind" to "Indonesian", + "may" to "Malay", +) + +/** + * "Japanese", for a tag in any of the forms a file or the desktop may have + * stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list. + * Null is no preference. + */ +internal fun languageLabel(tag: String?): String { + val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference" + return languageOf(value)?.second ?: value +} + +/** The listed language a tag names, matching either ISO 639 form. */ +private fun languageOf(tag: String): Pair? { + val base = tag.split('-', '_').first().lowercase() + return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base } +} + +/** ISO 639-2/T, for the handful whose bibliographic code differs. */ +private fun terminology(code: String): String? = when (code) { + "ger" -> "deu" + "fre" -> "fra" + "dut" -> "nld" + "cze" -> "ces" + "chi" -> "zho" + "rum" -> "ron" + "gre" -> "ell" + "may" -> "msa" + else -> null +} + +/** ISO 639-1, the form a file or the desktop may also have stored. */ +private val TWO_LETTER = mapOf( + "jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it", + "por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da", + "nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar", + "heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi", + "ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms", +) + +/** + * A language preference as a row, opening a list to choose from. + * + * It was a free-text field asking for "jpn or eng", which is a question only + * someone who has read a Matroska header can answer. + */ +@Composable +internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) { + var choosing by remember { mutableStateOf(false) } + ListItem( + headlineContent = { Text(headline) }, + supportingContent = { Text(languageLabel(value)) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(role = Role.Button) { choosing = true }, + ) + if (choosing) { + LanguageDialog( + headline, + value, + onDismiss = { choosing = false }, + onChoose = { + choosing = false + onChange(it) + }, + ) + } +} + +@Composable +private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) { + val current = value?.let(::languageOf)?.first ?: value + // A tag that is not on the list starts in the field, so it is shown and + // can be edited rather than silently replaced. + var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(headline) }, + text = { + Column { + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } } + items(LANGUAGES, key = { it.first }) { (code, name) -> + LanguageOption(name, current == code) { onChoose(code) } + } + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + OutlinedTextField( + value = typed, + onValueChange = { typed = it }, + label = { Text("Another tag") }, + supportingText = { Text("As it appears in the file, such as \"tgl\"") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) { + Text("Use tag") + } + }, + dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) }, + leadingContent = { RadioButton(selected = selected, onClick = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick), + ) +} 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 330a09d..b779d0a 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 @@ -1,6 +1,19 @@ package io.narl.protonstream.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ManageSearch +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -77,6 +90,7 @@ internal fun SettingsScreen( onClearCache: () -> Unit, onRemoveAllOffline: () -> Unit, padding: PaddingValues, + initialPage: SettingsPage? = null, ) { val context = LocalContext.current val settings = remember { SettingsStore(context) } @@ -103,12 +117,46 @@ internal fun SettingsScreen( var confirmDelete by remember { mutableStateOf(false) } var showMetadata by remember { mutableStateOf(false) } var legalDocument by remember { mutableStateOf(null) } - // 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. - TabPage("Settings", padding) { body -> - Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) { - SettingsGroup("Playback") + // One row per area on the first page, each opening its own page: grouped + // on one page, the storage controls sat a long scroll below playback, and + // every setting added made the rest harder to find. + var page by rememberSaveable { mutableStateOf(initialPage) } + BackHandler(enabled = page != null) { page = null } + val back: @Composable () -> Unit = { + IconButton(onClick = { page = null }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings") + } + } + when (page) { + null -> TabPage("Settings", padding) { body -> + Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) { + SettingsPage.entries.forEach { entry -> + ListItem( + headlineContent = { Text(entry.title) }, + supportingContent = { + Text( + when (entry) { + SettingsPage.Playback -> "Autoplay, languages and decoding" + SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " + + "${state.storage.offlineCount} offline" + SettingsPage.Appearance -> "Palette, accent and gradient" + SettingsPage.Metadata -> if (state.metadataSettings.enabled) { + "On · ${state.metadataSettings.provider.displayName()}" + } else { + "Off" + } + SettingsPage.About -> "Version ${appVersion()} · licences" + }, + ) + }, + leadingContent = { Icon(entry.icon, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { page = entry }, + ) + } + } + } + SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) { prefs?.let { current -> SettingSwitch( "Autoplay next episode", @@ -125,26 +173,15 @@ internal fun SettingsScreen( 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) } - } + // Preferences, not filters: a file with nothing in the language + // plays its own default track. A show that has been given its + // own choice keeps it. + LanguageSetting("Audio language", current.audioLanguage) { tag -> + update { it.copy(audioLanguage = tag) } + } + LanguageSetting("Subtitle language", current.subtitleLanguage) { 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), - ) } SettingSwitch( "Hardware decoding", @@ -162,8 +199,8 @@ internal fun SettingsScreen( backgroundAudio = it settings.backgroundAudio = it } - - SettingsGroup("Downloads and storage") + } + SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) { SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) { wifiOnly = it settings.wifiOnly = it @@ -222,11 +259,11 @@ internal fun SettingsScreen( enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL, onClick = { confirmDelete = true }, ) - - SettingsGroup("Appearance") + } + SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) { Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() } - - SettingsGroup("Metadata") + } + SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) { SettingAction( "Metadata enrichment", if (state.metadataSettings.enabled) { @@ -245,15 +282,19 @@ internal fun SettingsScreen( enabled = state.metadataSettings.enabled && !state.refreshing, onClick = onMatchAgain, ) - - SettingsGroup("About") + } + SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) { + ListItem( + headlineContent = { Text("Proton Stream") }, + supportingContent = { Text("Version ${appVersion()}") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) 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)) } } if (confirmDelete) { @@ -288,15 +329,39 @@ internal fun SettingsScreen( } } -/** A group's heading, in the accent, the way Android's own settings mark them. */ +/** The areas the first page lists, each a page of its own. */ +internal enum class SettingsPage(val title: String, val icon: ImageVector) { + Playback("Playback", Icons.Default.PlayCircle), + Storage("Downloads and storage", Icons.Default.Storage), + Appearance("Appearance", Icons.Default.Palette), + Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch), + About("About", Icons.Default.Info), +} + +/** One area's page: its name, a way back, and its rows. */ @Composable -private fun SettingsGroup(label: String) { - Text( - label, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp), - ) +private fun SettingsSubPage( + page: SettingsPage, + padding: PaddingValues, + back: @Composable () -> Unit, + rows: @Composable ColumnScope.() -> Unit, +) { + TabPage(page.title, padding, navigationIcon = back) { body -> + Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) { + rows() + Spacer(Modifier.height(24.dp)) + } + } +} + +/** The installed version, as the launcher and the store show it. */ +@Composable +private fun appVersion(): String { + val context = LocalContext.current + return remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } + .getOrNull() ?: "unknown" + } } /** A switch with its one line of explanation; the whole row toggles it. */ @@ -523,20 +588,3 @@ private fun FlavorChoice.label() = when (this) { FlavorChoice.MOCHA -> "Catppuccin Mocha" FlavorChoice.PERSONA5 -> "Persona 5" } - -/** - * One language tag, committed as it is typed. - * - * Blank is a real answer and means "no preference" — the bridge stores it as - * absent, which is what leaves the choice to the container's own default track. - */ -@Composable -private fun LanguageField(label: String, value: String?, modifier: Modifier = Modifier, onChange: (String?) -> Unit) { - OutlinedTextField( - value = value.orEmpty(), - onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) }, - label = { Text(label) }, - singleLine = true, - modifier = modifier, - ) -} diff --git a/android/app/src/test/java/io/narl/protonstream/ui/LanguagePickerTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/LanguagePickerTest.kt new file mode 100644 index 0000000..75ff9e7 --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/LanguagePickerTest.kt @@ -0,0 +1,31 @@ +package io.narl.protonstream.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LanguagePickerTest { + @Test + fun `a stored tag reads as its language in either ISO form`() { + assertEquals("Japanese", languageLabel("jpn")) + assertEquals("Japanese", languageLabel("ja")) + assertEquals("German", languageLabel("deu")) + assertEquals("German", languageLabel("ger")) + } + + @Test + fun `a region suffix and capitals do not hide the language`() { + assertEquals("Portuguese", languageLabel("PT-br")) + } + + @Test + fun `a tag not on the list is shown as typed, and blank is no preference`() { + assertEquals("tgl", languageLabel("tgl")) + assertEquals("No preference", languageLabel(null)) + assertEquals("No preference", languageLabel(" ")) + } + + @Test + fun `every listed language has a two-letter form`() { + LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) } + } +} diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index ee176e9..a495c98 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -108,6 +108,16 @@ class ScreenshotTest { SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues()) } + @Test + fun `the playback settings`() = capture("settings-playback") { + SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback) + } + + @Test + fun `the storage settings`() = capture("settings-storage") { + SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage) + } + @Test fun `the history page`() = capture("history-phone") { // Fixed day and zone: "Today" and "Yesterday" are relative, and an diff --git a/android/app/src/test/screenshots/settings-phone.png b/android/app/src/test/screenshots/settings-phone.png index 2b4d28b..024e501 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/settings-playback.png b/android/app/src/test/screenshots/settings-playback.png new file mode 100644 index 0000000..8e40aaf Binary files /dev/null and b/android/app/src/test/screenshots/settings-playback.png differ diff --git a/android/app/src/test/screenshots/settings-storage.png b/android/app/src/test/screenshots/settings-storage.png new file mode 100644 index 0000000..008a3c5 Binary files /dev/null and b/android/app/src/test/screenshots/settings-storage.png differ