mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android): settings as a page per area, and a language picker
The first Settings page lists Playback, Downloads and storage, Appearance, Metadata and About, each with a line saying where it stands, and each opens its own page with a way back. On one long page, every setting added pushed the rest further from reach. Audio and subtitle language are chosen from a list of languages by name instead of typed as "jpn or eng". The list stores the three-letter tag a Matroska file carries, reads a stored tag in either ISO form, and still takes any other tag typed in.
This commit is contained in:
1 parent
fb32d6c850
commit
677ab2d928
7 files changed
+331
-59
No files matched your search
@@ -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<Pair<String, String>> = 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<String, String>? {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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<LegalDocument?>(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,
|
||||
)
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Reference in new issue
Block a user