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
+213
-100
No files matched your search
@@ -90,3 +90,21 @@ internal fun formatBytes(bytes: ULong): String {
|
|||||||
else -> "$bytes bytes"
|
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
|
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.Card
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.download.DownloadStateStore
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
import io.narl.protonstream.download.RetainedDownload
|
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.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
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
|
@Composable
|
||||||
internal fun DownloadsScreen(
|
internal fun DownloadsScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
onPause: (RetainedDownload) -> Unit,
|
onPause: (RetainedDownload) -> Unit,
|
||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onDeletePartial: (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.
|
// but not its show, and the library is the only thing that does.
|
||||||
val groups = remember(state.offline, state.titles) {
|
val groups = remember(state.offline, state.titles) {
|
||||||
val shows = state.titles.flatMap { title ->
|
val shows = state.titles.flatMap { title ->
|
||||||
title.seasons.flatMap(SeasonRecord::episodes).map {
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
|
|
||||||
}
|
|
||||||
}.toMap()
|
}.toMap()
|
||||||
state.offline
|
state.offline
|
||||||
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
.toSortedMap()
|
.map { (title, files) ->
|
||||||
}
|
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files)
|
||||||
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()) {
|
.sortedBy { it.name }
|
||||||
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
|
}
|
||||||
|
// 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 ->
|
||||||
|
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
|
// Under the show they belong to, as on desktop: a saved season is
|
||||||
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||||
groups.forEach { (show, files) ->
|
groups.forEach { group ->
|
||||||
item(key = "group/$show") {
|
item(key = "group/${group.name}") {
|
||||||
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
|
SectionHeading(
|
||||||
Text(
|
group.name,
|
||||||
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
|
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||||
formatBytes(files.sumOf { it.size }),
|
formatBytes(group.files.sumOf { it.size }),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||||
val episode = file.episode
|
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||||
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") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
@Composable
|
||||||
private fun ChangeMatchDialog(
|
private fun ChangeMatchDialog(
|
||||||
title: TitleRecord,
|
title: TitleRecord,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.robolectric.RobolectricTestRunner
|
|||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.StorageUsageRecord
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
@@ -125,6 +126,16 @@ class ScreenshotTest {
|
|||||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
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
|
@Test
|
||||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
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