refactor(android): one file per screen

ProtonStreamApp.kt had grown to 1665 lines holding every screen, and
every planned UI change touches it. The shell, Library, Title, Shares,
Downloads and Settings screens now each have their own file, plus
Common.kt for the empty state and byte formatting they share. Code is
moved unchanged; declarations used across files went from private to
internal.
This commit is contained in:
nvrl committed 2026-10-03 03:45:50 +02:00
1 parent 2ac906cf37
commit 0d33799199
7 files changed
+1548 -1343

No files matched your search

@@ -0,0 +1,30 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
internal fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
}
internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
value >= 1024 -> "%.1f KiB".format(value / 1024)
else -> "$bytes bytes"
}
}
@@ -0,0 +1,139 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Arrangement
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.height
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.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.SeasonRecord
@Composable
internal fun DownloadsScreen(
state: AppUiState,
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
val downloads by work.observeAsState(emptyList())
// Reading retained metadata on every WorkInfo transition also hydrates
// paused/failed/cancelled entries after WorkManager history is pruned.
val retained = remember(downloads) { DownloadStateStore(context).records() }
// Which show each saved file is from. The offline record knows its episode
// but not its show, and the library is the only thing that does.
val groups = remember(state.offline, state.titles) {
val shows = state.titles.flatMap { title ->
title.seasons.flatMap(SeasonRecord::episodes).map {
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
}
}.toMap()
state.offline
.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),
)
}
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
) {
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
} else {
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
}
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
}
}
}
}
}
}
@@ -0,0 +1,171 @@
package io.narl.protonstream.ui
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.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.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
/**
* How many shows the continue-watching shelf holds.
*
* A shelf is a shortcut, not a second library: past about this many it is
* quicker to search for the thing than to scroll the shelf looking for it.
*/
private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
@Composable
internal fun LibraryScreen(
state: AppUiState,
onSearch: (String) -> Unit,
onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
padding: PaddingValues,
) {
// Most recently played first, one episode per show: a shelf that lists four
// episodes of the same series is a shelf with room for nothing else.
val resumable = remember(state.titles) {
state.titles.mapNotNull { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
playlist.withIndex()
.filter { it.value.resumeAt != null && !it.value.watched }
.maxByOrNull { it.value.lastPlayed }
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("Search library") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
)
Spacer(Modifier.height(16.dp))
if (state.loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (state.titles.isEmpty()) {
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(180.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
// Above the grid, and only when there is something in it: what a
// viewer opening the app wants is almost always the thing they
// were part way through, and finding it in an alphabetical grid
// is the long way round.
//
// Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest instead of holding a fixed slice of
// a phone screen for itself.
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp),
) {
items(
resumable,
key = { "${it.episode.shareId}/${it.episode.linkId}" },
) { entry ->
Card(
Modifier.width(200.dp)
.clickable { onResume(entry.title, entry.index) },
) {
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f),
fallback = entry.episode.thumbnailSource,
)
Column(Modifier.padding(10.dp)) {
Text(
entry.title.canonicalName ?: entry.title.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
entry.episode.label,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
entry.episode.progress?.let { progress ->
AccentProgress(
progress = {
progress.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth()
.padding(top = 8.dp),
)
}
}
}
}
}
}
}
}
items(state.titles, key = { it.key }) { title ->
Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().height(210.dp),
fallback = title.thumbnailSource,
)
Column(Modifier.padding(12.dp)) {
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,449 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
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.PaddingValues
import androidx.compose.foundation.layout.Row
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.LazyRow
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.settings.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.FlavorChoice
import uniffi.pstr_android.AppearanceRecord
import uniffi.pstr_android.AccentChoice
import uniffi.pstr_android.PlaybackPrefsRecord
@Composable
internal fun SettingsScreen(
state: AppUiState,
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
onMatchAgain: () -> Unit,
onClearCache: () -> Unit,
onRemoveAllOffline: () -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
// Playback preferences are the shared store's, not this app's: they are the
// same file the desktop client reads, so a language chosen on one is the
// language the other starts in.
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
val scope = rememberCoroutineScope()
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
val next = change(prefs ?: return)
prefs = next
scope.launch(Dispatchers.IO) {
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
}
}
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { prefs = it }
}
var confirmDelete by remember { mutableStateOf(false) }
var showMetadata by remember { mutableStateOf(false) }
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Settings", style = MaterialTheme.typography.titleLarge)
SettingToggle("Download on Wi-Fi only", 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)
}
HorizontalDivider()
SettingToggle("Continue audio in the background", backgroundAudio) {
backgroundAudio = it
settings.backgroundAudio = it
}
Text(
"When disabled, leaving playback stops the player instead of keeping a media notification active.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
HorizontalDivider()
Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
prefs?.let { current ->
SettingToggle("Play the next episode automatically", current.autoplayNext) { on ->
update { it.copy(autoplayNext = on) }
}
SettingToggle("Skip openings and endings automatically", current.autoSkip) { on ->
update { it.copy(autoSkip = on) }
}
Text(
"Openings and endings are read from the chapters a release was muxed with. " +
"With this off you get a Skip button instead, which is the safer default: " +
"a mis-named chapter then costs a tap rather than a scene.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
SettingToggle("Show subtitles", 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.
LanguageField("Preferred audio language", current.audioLanguage) { tag ->
update { it.copy(audioLanguage = tag) }
}
LanguageField("Preferred subtitle language", current.subtitleLanguage) { tag ->
update { it.copy(subtitleLanguage = tag) }
}
Text(
"Three-letter tags as they appear in the file — \"jpn\", \"eng\". Used for every " +
"title that has not been given a track choice of its own.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
}
HorizontalDivider()
Text("Appearance", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
AppearancePicker()
HorizontalDivider()
Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text(
if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}"
else "Off (privacy default)",
)
Text(
"Enabling this sends library title names to the selected third-party provider over HTTPS.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 10.dp)) {
TonalButton(onClick = { showMetadata = true }) { Text("Configure metadata") }
// 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.
TonalButton(
onClick = { onMatchAgain() },
enabled = state.metadataSettings.enabled && !state.refreshing,
) { Text("Match everything again") }
}
HorizontalDivider()
Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp))
Text("Offline media is encrypted at rest by Android and kept in app-private storage.")
Text(
"${state.storage.offlineCount} episodes offline · ${formatBytes(state.storage.offlineBytes)}",
modifier = Modifier.padding(top = 8.dp),
)
if (state.storage.partialBytes > 0uL) {
Text(
"Unfinished downloads · ${formatBytes(state.storage.partialBytes)}",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"Streaming cache · ${formatBytes(state.storage.cacheBytes)}",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) {
// The cache is rebuildable, so it goes without asking. Offline
// episodes are a choice the viewer made, so that one asks.
TonalButton(onClick = onClearCache) { Text("Clear cache") }
TonalButton(
onClick = { confirmDelete = true },
enabled = state.storage.offlineCount > 0uL,
) { Text("Delete all offline") }
}
Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp))
Text(
"This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
TonalButton(onClick = {
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
}) { Text("View license") }
TonalButton(onClick = {
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
}) { Text("View notices") }
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Delete all offline episodes?") },
text = {
Text(
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
"will be removed from this device. Watch history is kept, and anything " +
"deleted can be downloaded again.",
)
},
confirmButton = {
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
},
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
)
}
legalDocument?.let { document ->
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
}
if (showMetadata) {
MetadataSettingsDialog(
current = state.metadataSettings,
onDismiss = { showMetadata = false },
onSave = { enabled, provider, language, key ->
onSaveMetadata(enabled, provider, language, key)
showMetadata = false
},
)
}
}
@Composable
private fun MetadataSettingsDialog(
current: uniffi.pstr_android.MetadataSettingsRecord,
onDismiss: () -> Unit,
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
) {
var enabled by remember { mutableStateOf(current.enabled) }
var provider by remember { mutableStateOf(current.provider) }
var language by remember { mutableStateOf(current.language) }
var apiKey by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Metadata enrichment") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.")
SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option ->
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
else "Film and television; requires a free TMDB API key.",
style = MaterialTheme.typography.bodySmall,
)
if (provider == MetadataProvider.TMDB) {
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
singleLine = true,
)
}
}
},
confirmButton = {
AccentButton(
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
) { Text("Save") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
}
private data class LegalDocument(val title: String, val assetPath: String)
@Composable
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
val context = LocalContext.current
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
LaunchedEffect(document.assetPath) {
contents = runCatching {
withContext(Dispatchers.IO) {
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
}
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(document.title) },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(label, Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChecked)
}
}
/**
* Flavour, accent and gradients — the same three the desktop client offers.
*
* Every colour is resolved by Rust, so a swatch here is the colour the app will
* actually paint rather than an approximation of it, and the choice is stored in
* the file both clients read.
*/
@Composable
private fun AppearancePicker() {
val scope = rememberCoroutineScope()
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
suspend fun repaint(next: AppearanceRecord, store: Boolean) {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
if (store) engine.setAppearance(next)
val palette = engine.previewPalette(next)
// Every accent as it would look in *this* flavour: a swatch row
// that keeps Mocha's pastels while Latte is selected is a row
// that lies about what the next tap does.
val row = AccentChoice.entries.associateWith { accent ->
Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt())
}
palette to row
}
}.onSuccess { (palette, row) ->
choice = next
swatches = row
AppearanceState.apply(palette)
}
}
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } }
.onSuccess { repaint(it, store = false) }
}
val current = choice ?: return
Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(FlavorChoice.entries.toList(), key = { it.name }) { flavor ->
val selected = flavor == current.flavor
if (selected) {
AccentButton(onClick = {}) { Text(flavor.label()) }
} else {
EdgedButton(onClick = {
scope.launch { repaint(current.copy(flavor = flavor), store = true) }
}) { Text(flavor.label()) }
}
}
}
Text("Accent", style = MaterialTheme.typography.bodyMedium)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) {
items(AccentChoice.entries.toList(), key = { it.name }) { accent ->
val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(swatch)
.border(
width = if (accent == current.accent) 3.dp else 1.dp,
color = if (accent == current.accent) {
MaterialTheme.colorScheme.onBackground
} else {
MaterialTheme.colorScheme.outline
},
shape = CircleShape,
)
.clickable {
scope.launch { repaint(current.copy(accent = accent), store = true) }
},
)
}
}
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
scope.launch { repaint(current.copy(gradients = on), store = true) }
}
Text(
"Off is the safer setting on a panel that bands: a slow ramp across a wide bar " +
"shows every step it is drawn from, and flat is better than striped.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 14.dp),
)
}
/** What each palette family is called. The desktop client says the same. */
private fun FlavorChoice.label() = when (this) {
FlavorChoice.PROTON -> "Proton"
FlavorChoice.LATTE -> "Catppuccin Latte"
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
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?, onChange: (String?) -> Unit) {
OutlinedTextField(
value = value.orEmpty(),
onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) },
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
@@ -0,0 +1,188 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.ShareRecord
@Composable
internal fun SharesScreen(
shares: List<ShareRecord>,
onAdd: (String, String, String?) -> Unit,
onRepair: (String, String, String?) -> Unit,
onRefresh: (String) -> Unit,
onRemove: (String) -> Unit,
padding: PaddingValues,
) {
var showAdd by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge)
AccentButton(onClick = { showAdd = true }) { Text("Add share") }
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) {
items(shares, key = { it.id }) { share ->
Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(share.name, fontWeight = FontWeight.SemiBold)
Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link")
}
// One share, not the library: a link that has just had
// files added to it should not cost a walk of every
// other one, and a link that has expired should not stop
// the ones that still work from being refreshed.
IconButton(onClick = { onRefresh(share.id) }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh this share")
}
// Not a remove: a share whose secret has become
// unreadable cannot be removed either, since removal
// deletes a secret the store can no longer touch.
QuietButton(onClick = { repairing = share }) { Text("Re-enter link") }
TonalButton(onClick = { onRemove(share.id) }) { Text("Remove") }
}
}
}
}
}
if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd)
repairing?.let { share ->
RepairShareDialog(
share = share,
onDismiss = { repairing = null },
onRepair = { url, password -> onRepair(share.id, url, password) },
)
}
}
/**
* Re-enter the link for a share the app can no longer decrypt its secret for.
*
* It has to be the *same* link — a different token is a different share, and
* repointing this one at it would leave every catalog row and every offline file
* describing something that is not there. Rust enforces that; this only says so.
*/
@Composable
private fun RepairShareDialog(
share: ShareRecord,
onDismiss: () -> Unit,
onRepair: (String, String?) -> Unit,
) {
var url by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Re-enter link for ${share.name}") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"The stored credentials for this share cannot be read — usually after a " +
"screen-lock change or a device restore. Entering the same link again " +
"restores access without losing the library or anything downloaded.",
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
AccentButton(
onClick = { onRepair(url.trim(), password); onDismiss() },
enabled = url.isNotBlank(),
) { Text("Restore") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Add Proton Drive share") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
OutlinedTextField(
url,
{ url = it },
label = { Text("Public share URL") },
singleLine = true,
// The URL fragment is a secret. Password input disables
// IME learning/suggestions while normal long-press paste
// remains available.
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
password,
{ password = it },
label = { Text("Custom password (optional)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
AccentButton(
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
enabled = name.isNotBlank() && url.isNotBlank(),
) { Text("Add") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,571 @@
package io.narl.protonstream.ui
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
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.FlowRow
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.livedata.observeAsState
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentProgress
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord
@Composable
internal fun TitleScreen(
title: TitleRecord,
playerReady: Boolean,
onPlay: (List<EpisodeRecord>, Int) -> Unit,
onBack: () -> Unit,
onPreferenceError: (Throwable) -> Unit,
onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var audioLanguage by remember(title.key) { mutableStateOf("") }
var subtitleLanguage by remember(title.key) { mutableStateOf("") }
var subtitlesEnabled by remember(title.key) { mutableStateOf(false) }
var showMatch by remember(title.key) { mutableStateOf(false) }
var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) }
BackHandler(onBack = onBack)
LaunchedEffect(title.key) {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().titleTrackPreferences(title.key)
}
}.onSuccess { preferences ->
// Absent means this title has never been given a choice; the fields
// are then empty and the global preference is what plays.
audioLanguage = preferences?.audioLanguage.orEmpty()
subtitleLanguage = preferences?.subtitleLanguage.orEmpty()
subtitlesEnabled = preferences?.subtitles ?: true
}.onFailure(onPreferenceError)
}
// Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses.
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
// Where a press on Play lands. Part-watched wins over unwatched, most
// recently played first, which is the order the desktop client resumes in.
val nextUp = remember(playlist) {
val resumable = playlist.withIndex().filter { it.value.resumeAt != null }
resumable.maxByOrNull { it.value.lastPlayed }?.index
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
}
// Live download state per episode, so a row can show progress and be paused
// without a trip to the Downloads tab.
val work by WorkManager.getInstance(context)
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
val downloads = remember(work) { DownloadStateStore(context).records() }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
QuietButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Back to library")
}
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp),
fallback = title.thumbnailSource,
)
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp))
title.originalName?.takeIf { it != title.canonicalName }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
Text(
listOfNotNull(
(title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "%.1f/10".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "),
).joinToString(" • "),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 6.dp),
)
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
// Wrapping, not squeezing: a fixed row hands the last button the
// width the others left over, which is how "More on AniList" ended
// up set one letter per line on a phone.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
// What a press on the poster should play: whatever was left
// part-watched, else the first unwatched episode, else the
// first — the desktop client's `next_up`.
AccentButton(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) {
Text(
when {
!playerReady -> "Player loading…"
playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume"
title.watchedCount > 0uL -> "Continue"
else -> "Play"
},
)
}
// Only where it would do something different: an unstarted show
// already starts at the beginning.
if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) {
TonalButton(
onClick = { onPlay(playlist, 0) },
enabled = playerReady && playlist.isNotEmpty(),
) { Text("Start over") }
}
// The rest in the same flow, and all the same kind of button:
// one filled primary for the thing the page is for, tonal for
// everything else. A text link among filled buttons was what
// made this block read as three unrelated rows of controls.
TonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Download show")
}
TonalButton(onClick = { showMatch = true }) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Change match")
}
// The provider's own page for this title: where a viewer goes to
// check that the thing the app matched is the thing they have.
title.externalUrl?.let { url ->
EdgedButton(onClick = {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
}) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") }
}
}
// Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) {
Text(
"Matched by hand",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
audioLanguage,
{ audioLanguage = it },
label = { Text("Audio language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedTextField(
subtitleLanguage,
{ subtitleLanguage = it },
label = { Text("Subtitle language") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it }
TonalButton(onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().setTitleTrackPreferences(
title.key,
TrackPreferencesRecord(
audioLanguage.takeIf(String::isNotBlank),
subtitleLanguage.takeIf(String::isNotBlank),
subtitlesEnabled,
),
)
}
}.onFailure(onPreferenceError)
}
}) { Text("Save track preferences") }
}
title.seasons.forEach { season ->
val isExpanded = expandedSeasons.contains(season.label)
item(key = "season/${season.label}") {
// A header, not a row with a button parked on the end of it: the
// label and its count are the only things that grow, and the one
// action is an icon of fixed width, so every season header down
// the page starts and ends in the same place.
Row(
Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable {
expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label
}
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand",
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
season.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val watched = season.episodes.count(EpisodeRecord::watched)
Text(
"${season.episodes.size} " +
(if (season.episodes.size == 1) "episode" else "episodes") +
if (watched > 0) " · $watched watched" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) {
Icon(
Icons.Default.Download,
contentDescription = "Download ${season.label}",
)
}
}
}
if (isExpanded) {
itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow(
episode = episode,
numbering = episode.numbering(season.number, position),
download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId
},
playerReady = playerReady,
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
onDownload = { DownloadCoordinator.enqueue(context, episode) },
onPause = { DownloadCoordinator.pause(context, it) },
onResume = { DownloadCoordinator.resume(context, it) },
onSetWatched = { onSetWatched(episode, it) },
)
}
}
}
}
if (showMatch) {
ChangeMatchDialog(
title = title,
onDismiss = { showMatch = false },
onChanged = { showMatch = false; onMetadataChanged() },
onError = onPreferenceError,
)
}
}
/**
* `S03E38`, `E38`, or the row's place in the season.
*
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
* fallback to the season folder's number. The last case is deliberately *not*
* printed as an episode number — nothing in the name numbered this file, and
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
* Some ordinal is required regardless: a season of rows that all read the same
* is a season the viewer cannot navigate at all.
*/
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
val season = this.season ?: fallbackSeason
val number = this.number
return when {
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
number != null -> "E%02d".format(number.toInt())
else -> "#${position + 1}"
}
}
/**
* One episode: play it, see where it was left, and drive its download.
*
* The row *is* the play button — the still, the numbering and the name are one
* target, which is what lets the trailing controls be two icons in a fixed
* column rather than a second row of buttons under every episode. Both were
* needed while the row carried a full-width `Play`: it pushed the download and
* watched controls into whatever width was left, and forty of those down a
* season read as forty differently-arranged rows.
*/
@Composable
private fun EpisodeRow(
episode: EpisodeRecord,
numbering: String,
download: RetainedDownload?,
playerReady: Boolean,
onPlay: () -> Unit,
onDownload: () -> Unit,
onPause: (RetainedDownload) -> Unit,
onResume: (RetainedDownload) -> Unit,
onSetWatched: (Boolean) -> Unit,
) {
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED
// The provider's name for the episode when there is one: "The Cave of
// Skulls" says more than the filename it was parsed out of. The filename
// stays underneath it, and is the whole answer when there is no provider.
val name = episode.providerName ?: episode.detail
val detail = episode.detail.takeIf { it != name }
Card(
Modifier
.fillMaxWidth()
.clickable(enabled = playerReady, onClick = onPlay),
) {
Column(Modifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// With metadata off the provider has no still, and Proton's own
// thumbnail is a frame of this very episode.
Box(
Modifier
.width(96.dp)
.aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small),
contentAlignment = Alignment.Center,
) {
RemoteArtwork(
episode.stillUrl,
name,
Modifier.fillMaxSize(),
fallback = episode.thumbnailSource,
)
// Over the still rather than beside it: the row already
// plays on tap, so this says so without spending a column.
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.padding(4.dp),
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
numbering,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
name,
fontWeight = FontWeight.SemiBold,
// Seen episodes stay legible but stop competing with the
// one the viewer has not watched yet.
color = if (episode.watched) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Watched is a judgement the viewer is allowed to overrule: a
// half-watched episode they are done with, or one the 90 % rule
// marked seen because they sat through the credits.
IconButton(onClick = { onSetWatched(!episode.watched) }) {
Icon(
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
tint = if (episode.watched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
},
)
}
// One slot, always the same width, whatever state the download
// is in — including "there is nothing to say", where an empty
// box keeps the rows above and below it aligned.
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
when {
episode.offline -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Saved offline",
tint = MaterialTheme.colorScheme.tertiary,
)
running -> IconButton(onClick = { onPause(download!!) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
download != null -> IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.Download, contentDescription = "Resume download")
}
else -> IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, contentDescription = "Download")
}
}
}
}
// Where the episode was left, under the row it belongs to.
episode.progress?.takeIf { !episode.watched }?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
AccentProgress(
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
fill = solid(MaterialTheme.colorScheme.tertiary),
)
Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " +
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ChangeMatchDialog(
title: TitleRecord,
onDismiss: () -> Unit,
onChanged: () -> Unit,
onError: (Throwable) -> Unit,
) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Change match") },
text = {
Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
}.onSuccess { options = it }.onFailure(onError)
searching = false
}
}) { Text(if (searching) "Searching…" else "Search") }
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError)
}
},
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) {
Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
}
}
}
}
}
},
confirmButton = {
QuietButton(onClick = {
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
},
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
)
}