mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android): a downloads page of rows under each show
Saved episodes are list rows with their still, the episode's name rather than its file, and its size, under a heading per show; the page opens with how much is on the device. Downloads still arriving come first, with pause or resume and cancel as icons beside the progress. Deleting a saved episode now asks first, since getting it back is a download.
This commit is contained in:
1 parent
677ab2d928
commit
6c2ec83cb1
6 files changed
+201
-88
No files matched your search
@@ -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}" }
|
||||
}
|
||||
}
|
||||
@@ -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<OfflineRecord>)
|
||||
|
||||
@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()
|
||||
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||
.map { (title, files) ->
|
||||
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files)
|
||||
}
|
||||
.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<OfflineRecord?>(null) }
|
||||
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))
|
||||
}
|
||||
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
|
||||
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 ->
|
||||
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
|
||||
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))
|
||||
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")
|
||||
}
|
||||
TonalButton(onClick = { onRemove(file) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Delete")
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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() })
|
||||
|
||||
/**
|
||||
* 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 = { progress },
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
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") }
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (active) {
|
||||
IconButton(onClick = { onPause(download) }) {
|
||||
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||
}
|
||||
} else {
|
||||
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
|
||||
}
|
||||
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Reference in new issue
Block a user