feat(app,android): play from the Downloads page, and say why a download waits

- A finished download on the desktop could only be deleted from the
  Downloads page; watching it meant finding it in the library. Finished
  rows gain Play, from wherever the library says it was left, and Open.
- Android showed "Queued" for a download WorkManager was holding back
  on mobile data because of Wi-Fi only, or for want of any connection,
  which reads as stuck. Those now say "Waiting for Wi-Fi" and "Waiting
  for a connection".
This commit is contained in:
nvrl committed 2026-10-12 22:55:16 +02:00
1 parent 01c252b794
commit 690455f7dd
5 files changed
+54 -6

No files matched your search

@@ -1,5 +1,6 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
@@ -16,13 +17,16 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -139,10 +143,12 @@ internal fun formatBytes(bytes: ULong): String {
/** /**
* One line under a download that has not finished: "Running · 120.0 MiB of * One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot. * 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
* [waitingFor], what it is really waiting on.
*/ */
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull( internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
download.status.replaceFirstChar { it.uppercase() }, waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
?: download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let { download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}" "${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
}, },
@@ -151,6 +157,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
?.let { "${formatBytes(it.toULong())}/s" }, ?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ") ).joinToString(" · ")
/**
* Why a queued download is not running, when it is not merely a free slot:
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
* changes — the download's own updates are the clock.
*/
@Composable
internal fun rememberQueueReason(key: Any?): String? {
val context = LocalContext.current
return remember(key) {
val network = context.getSystemService(ConnectivityManager::class.java)
when {
network?.activeNetwork == null -> "Waiting for a connection"
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
else -> null
}
}
}
/** /**
* What an episode row is headed with: "1. Mother and Children". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -204,7 +204,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = { supportingContent = {
Column { Column {
Text(downloadStatusLine(download)) Text(downloadStatusLine(download, rememberQueueReason(download)))
if (download.total > 0L) { if (download.total > 0L) {
AccentProgress( AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -651,7 +651,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
downloadStatusLine(active), downloadStatusLine(active, rememberQueueReason(active)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
+15
View File
@@ -112,6 +112,9 @@ const NAV: [Page; 5] = [
pub enum Action { pub enum Action {
Goto(Page), Goto(Page),
Play(PlaybackTarget), Play(PlaybackTarget),
/// Play a file from wherever the library says it was left, for a caller
/// whose target may be older than that — a download queued last week.
PlayResuming(PlaybackTarget),
/// Download one or more files as complete local copies. /// Download one or more files as complete local copies.
MakeOffline(Vec<PlaybackTarget>), MakeOffline(Vec<PlaybackTarget>),
PauseDownload(DownloadKey), PauseDownload(DownloadKey),
@@ -1559,6 +1562,18 @@ impl App {
self.form.repair_password.clear(); self.form.repair_password.clear();
self.engine.repair_share(id, url, password); self.engine.repair_share(id, url, password);
} }
Action::PlayResuming(mut target) => {
target.resume_at = self.library.titles.iter().find_map(|title| {
title
.episodes()
.find(|episode| {
episode.node.share_id == target.share_id
&& episode.node.link_id == target.link_id
})
.and_then(|episode| episode.resume_at())
});
self.apply(ctx, Action::Play(target));
}
Action::OpenShareForm => { Action::OpenShareForm => {
self.page = Page::Shares; self.page = Page::Shares;
self.form.focus = true; self.form.focus = true;
+9 -1
View File
@@ -1,6 +1,6 @@
//! Inspectable desktop download queue. //! Inspectable desktop download queue.
use crate::app::Action; use crate::app::{Action, Page};
use crate::engine::{DownloadItem, DownloadState}; use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui}; use crate::{theme, ui};
@@ -222,6 +222,14 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
} }
} }
DownloadState::Completed => { DownloadState::Completed => {
// The point of a download is to watch it, and the library is a
// detour from here — offline most of all.
if ui::accent_button(ui, "Play").clicked() {
actions.push(Action::PlayResuming(item.target.clone()));
}
if ui.button("Open").on_hover_text("Go to its title").clicked() {
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
}
if ui if ui
.button("Delete") .button("Delete")
.on_hover_text("Delete the offline copy; keep the online source") .on_hover_text("Delete the offline copy; keep the online source")