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 48e1fe8..4665c1c 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 @@ -1,5 +1,6 @@ package io.narl.protonstream.ui +import android.net.ConnectivityManager import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -16,13 +17,16 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.narl.protonstream.download.RetainedDownload +import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.theme.AccentButton 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 - * 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( - download.status.replaceFirstChar { it.uppercase() }, +internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull( + waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED } + ?: download.status.replaceFirstChar { it.uppercase() }, download.total.takeIf { it > 0L && download.downloaded > 0L }?.let { "${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}" }, @@ -151,6 +157,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN ?.let { "${formatBytes(it.toULong())}/s" }, ).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". * 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 2f32444..d4fd0bd 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 @@ -204,7 +204,7 @@ private fun PartialRow( headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, supportingContent = { Column { - Text(downloadStatusLine(download)) + Text(downloadStatusLine(download, rememberQueueReason(download))) if (download.total > 0L) { AccentProgress( progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, 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 f809958..8ee016d 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 @@ -651,7 +651,7 @@ private fun EpisodeRow( fill = solid(MaterialTheme.colorScheme.tertiary), ) Text( - downloadStatusLine(active), + downloadStatusLine(active, rememberQueueReason(active)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index e35211e..3412765 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -112,6 +112,9 @@ const NAV: [Page; 5] = [ pub enum Action { Goto(Page), 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. MakeOffline(Vec), PauseDownload(DownloadKey), @@ -1559,6 +1562,18 @@ impl App { self.form.repair_password.clear(); 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 => { self.page = Page::Shares; self.form.focus = true; diff --git a/crates/pstr-app/src/ui/downloads.rs b/crates/pstr-app/src/ui/downloads.rs index aa7dd30..63b3697 100644 --- a/crates/pstr-app/src/ui/downloads.rs +++ b/crates/pstr-app/src/ui/downloads.rs @@ -1,6 +1,6 @@ //! Inspectable desktop download queue. -use crate::app::Action; +use crate::app::{Action, Page}; use crate::engine::{DownloadItem, DownloadState}; use crate::{theme, ui}; @@ -222,6 +222,14 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { } } 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 .button("Delete") .on_hover_text("Delete the offline copy; keep the online source")