diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 2b51607..83e6012 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -90,3 +90,21 @@ internal fun formatBytes(bytes: ULong): String { else -> "$bytes bytes" } } + +/** + * What an episode row is headed with: "1. Mother and Children". + * + * The provider's name for the episode, else the name the filename gave it, + * else "Episode 1". The bare filename only when none of those exist — a column + * of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters. + */ +internal fun EpisodeRecord.heading(position: Int): String { + val named = providerName ?: detail.takeIf { it != name } + val number = number?.toInt() + return when { + named != null && number != null -> "$number. $named" + named != null -> named + number != null -> "Episode $number" + else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt index 1310f36..b1ff394 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -1,47 +1,59 @@ package io.narl.protonstream.ui -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.Spacer +import androidx.compose.foundation.layout.aspectRatio 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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults 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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.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.AccentButton 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.OfflineRecord import uniffi.pstr_android.SeasonRecord +import uniffi.pstr_android.TitleRecord + +/** One show's saved episodes, and the show itself where the library has it. */ +private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List) @Composable internal fun DownloadsScreen( state: AppUiState, - onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit, + onRemove: (OfflineRecord) -> Unit, onPause: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit, onDeletePartial: (RetainedDownload) -> Unit, @@ -57,85 +69,175 @@ internal fun DownloadsScreen( // 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) - } + title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title } }.toMap() state.offline - .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" } - .toSortedMap() - } - TabPage("Downloads", padding) { body -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(body).padding(horizontal = 16.dp), - contentPadding = PaddingValues(bottom = 16.dp), - ) { - item { - Text("Offline downloads", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) + .groupBy { shows["${it.shareId}/${it.linkId}"] } + .map { (title, files) -> + OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files) } - if (retained.isEmpty() && state.offline.isEmpty()) { - item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } + .sortedBy { it.name } + } + // Deleting a saved episode asks first: it is the viewer's own choice to + // keep it, and getting it back is a download, not a tap. + var deleting by remember { mutableStateOf(null) } + TabPage("Downloads", padding) { body -> + if (retained.isEmpty() && state.offline.isEmpty()) { + Box(Modifier.fillMaxSize().padding(body)) { + EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.") + } + return@TabPage + } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(body), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + if (state.offline.isNotEmpty()) { + item(key = "summary") { + Text( + "${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " + + "${formatBytes(state.offline.sumOf { it.size })} on this device", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + // What is still arriving comes first: it is the part of the page + // that changes, and the part with something to do. + if (retained.isNotEmpty()) { + item(key = "in-progress") { SectionHeading("In progress") } + items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download -> + PartialRow(download, onPause, onResume, onDeletePartial) + } } // 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), + groups.forEach { group -> + item(key = "group/${group.name}") { + SectionHeading( + group.name, + "${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " + + formatBytes(group.files.sumOf { it.size }), ) } - 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") } - } - } + items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file -> + SavedRow(file, group.title, onDelete = { deleting = file }) } } } } + deleting?.let { file -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text("Delete this download?") }, + text = { + Text( + "${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " + + "It still streams, and can be downloaded again.", + ) + }, + confirmButton = { + AccentButton(onClick = { + deleting = null + onRemove(file) + }) { Text("Delete") } + }, + dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun SectionHeading(title: String, caption: String? = null) { + Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + caption?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** A saved episode: its still, what it is, its size, and a way to remove it. */ +@Composable +private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) { + val episode = file.episode + ListItem( + leadingContent = { + RemoteArtwork( + episode?.stillUrl ?: title?.backdropUrl, + title?.name ?: file.linkId, + Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small), + fallback = episode?.thumbnailSource, + labelled = episode == null, + ) + }, + headlineContent = { + Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · ")) + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, contentDescription = "Delete download") + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +/** + * A download not yet finished: how far it has got, why it stopped if it did, + * and pause or resume beside cancel. + */ +@Composable +private fun PartialRow( + download: RetainedDownload, + onPause: (RetainedDownload) -> Unit, + onResume: (RetainedDownload) -> Unit, + onDeletePartial: (RetainedDownload) -> Unit, +) { + val active = download.status == RetainedDownload.STATUS_RUNNING || + download.status == RetainedDownload.STATUS_QUEUED + ListItem( + headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + supportingContent = { + Column { + Text( + listOfNotNull( + download.status.replaceFirstChar { it.uppercase() }, + download.total.takeIf { it > 0L }?.let { + "${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}" + }, + ).joinToString(" · "), + ) + if (download.total > 0L) { + AccentProgress( + progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (active) { + IconButton(onClick = { onPause(download) }) { + Icon(Icons.Default.Pause, contentDescription = "Pause download") + } + } else { + IconButton(onClick = { onResume(download) }) { + Icon(Icons.Default.PlayArrow, contentDescription = "Resume download") + } + } + // What has arrived so far is discarded; nothing finished is. + IconButton(onClick = { onDeletePartial(download) }) { + Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived") + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index e79676a..0d63b9a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -487,24 +487,6 @@ private fun EpisodeRow( } } -/** - * What an episode row is headed with: "1. Mother and Children". - * - * The provider's name for the episode, else the name the filename gave it, - * else "Episode 1". The bare filename only when none of those exist — a column - * of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters. - */ -private fun EpisodeRecord.heading(position: Int): String { - val named = providerName ?: detail.takeIf { it != name } - val number = number?.toInt() - return when { - named != null && number != null -> "$number. $named" - named != null -> named - number != null -> "Episode $number" - else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" } - } -} - @Composable private fun ChangeMatchDialog( title: TitleRecord, 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 a495c98..65650c7 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 @@ -24,6 +24,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.StorageUsageRecord @@ -125,6 +126,16 @@ class ScreenshotTest { HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21)) } + @Test + fun `the downloads page with a season saved`() = capture("downloads-phone") { + val saved = SERIES.seasons.first().episodes.take(2).map { + OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it) + } + // One title: the fixtures reuse link ids across titles, so with the + // whole library the episodes would be filed under whichever came last. + DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues()) + } + @Test fun `the downloads page with nothing offline`() = capture("downloads-empty") { DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues()) diff --git a/android/app/src/test/screenshots/downloads-empty.png b/android/app/src/test/screenshots/downloads-empty.png index 27fba5b..823b4e1 100644 Binary files a/android/app/src/test/screenshots/downloads-empty.png and b/android/app/src/test/screenshots/downloads-empty.png differ diff --git a/android/app/src/test/screenshots/downloads-phone.png b/android/app/src/test/screenshots/downloads-phone.png new file mode 100644 index 0000000..1a2860a Binary files /dev/null and b/android/app/src/test/screenshots/downloads-phone.png differ