mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user