feat(android,ios): play from Downloads, and say what a download waits for

- A finished download row on the phones plays when tapped and has an
  Open button for its title, as the desktop's does.
- iOS queued downloads say "Waiting for Wi-Fi" or "Waiting for a
  connection", as Android's already do.
- Android download workers post their notifications as one group, each
  named for its episode, under a "Downloading 12 episodes" summary.
  Before, a season made fourteen loose notifications that all said
  "Making episode available offline".
This commit is contained in:
nvrl committed 2026-10-13 15:48:59 +02:00
1 parent 5c0519a226
commit 5b931111b3
9 files changed
+142 -19

No files matched your search

@@ -1,11 +1,15 @@
package io.narl.protonstream.download
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
createNotificationChannel()
setForeground(createForegroundInfo(null))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext)
setForeground(createForegroundInfo(label, null))
postSummary(store)
try {
return transfer(store, shareId, volumeId, linkId, label)
} finally {
postSummary(store)
}
}
private suspend fun transfer(
store: DownloadStateStore,
shareId: String,
volumeId: String,
linkId: String,
label: String,
): Result {
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
reportedAt = now
reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
setForegroundAsync(createForegroundInfo(percent))
setForegroundAsync(createForegroundInfo(label, percent))
// Under the store's lock: `pause()` is a concurrent writer, and
// this read-modify-write used to put RUNNING back over the
// viewer's PAUSED and then report the download as cancelled.
@@ -120,15 +139,20 @@ class OfflineDownloadWorker(
)
}
/** [progress] is a percentage, or null while waiting for a download slot. */
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
/**
* This episode's line, under [GROUP]: a season is fourteen workers, and
* fourteen loose notifications saying the same thing named nothing.
* [progress] is a percentage, or null while waiting for a download slot.
*/
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle(
if (progress == null) "Waiting to download" else "Making episode available offline",
)
.setContentTitle(label)
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.build()
return ForegroundInfo(
stableNotificationId(id),
@@ -137,6 +161,36 @@ class OfflineDownloadWorker(
)
}
/**
* The one line the group folds into — "Downloading 12 episodes" — or none
* once nothing is left to download. Posted as each worker starts and
* ends rather than on progress, which each episode's own line carries.
*/
private fun postSummary(store: DownloadStateStore) {
val manager = applicationContext.getSystemService(NotificationManager::class.java)
val left = store.records().count {
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
}
if (left == 0) {
manager.cancel(SUMMARY_ID)
return
}
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed) return
manager.notify(
SUMMARY_ID,
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.setGroupSummary(true)
.build(),
)
}
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
companion object {
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads"
private const val GROUP = "offline-downloads"
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
internal const val SUMMARY_ID = 1
/** Shortest gap between two identical-looking progress reports. */
private const val PROGRESS_INTERVAL_MS = 1_000L
@@ -186,4 +244,4 @@ internal class TransferRate {
}
internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@@ -16,6 +17,7 @@ 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.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues,
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
onOpen: (TitleRecord) -> Unit = {},
) {
val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
)
}
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
SavedRow(file, group.title, onDelete = { deleting = file })
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
}
}
}
@@ -160,9 +164,23 @@ private fun SectionHeading(title: String, caption: String? = null) {
/** 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) {
private fun SavedRow(
file: OfflineRecord,
title: TitleRecord?,
onDelete: () -> Unit,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
) {
val episode = file.episode
// What a download is for: tapping it plays it, from where it was left,
// without going back through the library to find it.
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
ListItem(
modifier = if (title != null && index >= 0) {
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
} else {
Modifier
},
leadingContent = {
RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl,
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
Row {
title?.let {
IconButton(onClick = { onOpen(it) }) {
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -329,6 +329,15 @@ fun ProtonStreamApp(
model::resumeDownload,
model::deletePartial,
body,
onPlay = { title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
onOpen = { title ->
destination = Destination.Library
selectedTitleKey = title.key
},
)
Destination.Settings -> SettingsScreen(
state,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+4 -1
View File
@@ -185,7 +185,10 @@ struct RootView: View {
// on a form that has done its job.
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
case .downloads:
DownloadsView()
DownloadsView(onPlay: play, onOpen: { title in
destination = .library
selectedTitleKey = title.key
})
case .settings:
SettingsView()
}
@@ -19,6 +19,11 @@ final class DownloadCoordinator {
/// Every retained download, sorted by label. Re-read after each write.
private(set) var records: [RetainedDownload] = []
/// Why a queued download is not running, when it is not merely a free
/// slot: a Wi-Fi-only download on mobile data waits without a word, and
/// "Queued" for an hour on the bus reads as stuck.
private(set) var queueReason: String?
/// A download finished or was removed: the catalog has changed.
@ObservationIgnored var onFinished: (() -> Void)?
@@ -125,6 +130,10 @@ final class DownloadCoordinator {
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued
/// before the setting was turned on must not then download over cellular.
func applyNetworkPolicy() {
queueReason = switch path?.status {
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
default: "Waiting for a connection"
}
if allowed {
resumeQueued()
} else {
+5 -3
View File
@@ -327,9 +327,11 @@ func formatBytes(_ bytes: UInt64) -> 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.
func downloadStatusLine(_ download: RetainedDownload) -> String {
var parts = [download.status.prefix(1).uppercased() + download.status.dropFirst()]
/// 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.
func downloadStatusLine(_ download: RetainedDownload, waitingFor: String? = nil) -> String {
let status = download.status == RetainedDownload.statusQueued ? waitingFor : nil
var parts = [status ?? download.status.prefix(1).uppercased() + download.status.dropFirst()]
if download.total > 0, download.downloaded > 0 {
parts.append("\(formatBytes(UInt64(download.downloaded))) of \(formatBytes(UInt64(download.total)))")
}
+19 -2
View File
@@ -9,6 +9,8 @@ private struct OfflineGroup: Identifiable {
}
struct DownloadsView: View {
var onPlay: (TitleRecord, Int) -> Void = { _, _ in }
var onOpen: (TitleRecord) -> Void = { _ in }
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
// Deleting a saved episode asks first: it is the viewer's own choice to
@@ -48,7 +50,7 @@ struct DownloadsView: View {
caption: "\(group.files.count) \(group.files.count == 1 ? "episode" : "episodes") · \(formatBytes(group.files.reduce(0) { $0 + $1.size }))"
)
ForEach(group.files, id: \.linkId) { file in
SavedRow(file: file, title: group.title) { deleting = file }
SavedRow(file: file, title: group.title, onPlay: onPlay, onOpen: onOpen) { deleting = file }
}
}
}
@@ -102,11 +104,16 @@ private struct SectionHeading: View {
private struct SavedRow: View {
let file: OfflineRecord
let title: TitleRecord?
let onPlay: (TitleRecord, Int) -> Void
let onOpen: (TitleRecord) -> Void
let onDelete: () -> Void
@Environment(\.scheme) private var scheme
var body: some View {
let episode = file.episode
// What a download is for: tapping it plays it, from where it was
// left, without going back through the library to find it.
let index = title?.playlist.firstIndex { $0.linkId == file.linkId }
HStack(spacing: 16) {
RemoteArtwork(episode?.stillUrl ?? title?.backdropUrl, title?.name ?? file.linkId, fallback: episode?.thumbnailSource, labelled: episode == nil)
.frame(width: 96)
@@ -118,11 +125,21 @@ private struct SavedRow: View {
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
}
Spacer(minLength: 0)
if let title {
IconButton("info.circle", "Open \(title.displayName)") { onOpen(title) }
}
IconButton("trash", "Delete download", action: onDelete)
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
if let title, let index { onPlay(title, index) }
}
.accessibilityAction(named: "Play") {
if let title, let index { onPlay(title, index) }
}
}
}
@@ -138,7 +155,7 @@ private struct PartialRow: View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(download.label).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
Text(downloadStatusLine(download)).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
Text(downloadStatusLine(download, waitingFor: DownloadCoordinator.shared.queueReason)).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
if download.total > 0 {
AccentProgress(progress: Double(download.downloaded) / Double(download.total)).padding(.top, 8)
}
+1 -1
View File
@@ -462,7 +462,7 @@ private struct EpisodeRow: View {
if let active = download, active.total > 0, !episode.offline {
AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary)
.padding(.top, 8)
Text(downloadStatusLine(active)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
Text(downloadStatusLine(active, waitingFor: DownloadCoordinator.shared.queueReason)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
}
}
.padding(.horizontal, 16)