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 package io.narl.protonstream.download
import android.Manifest
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.content.Context import android.content.Context
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(null))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) 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 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 linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext) 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 // Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means // and holds the rest back, and only the first progress report means
// this one has a slot. // this one has a slot.
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
reportedAt = now reportedAt = now
reportedPercent = percent reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong())) 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 // Under the store's lock: `pause()` is a concurrent writer, and
// this read-modify-write used to put RUNNING back over the // this read-modify-write used to put RUNNING back over the
// viewer's PAUSED and then report the download as cancelled. // 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) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle( .setContentTitle(label)
if (progress == null) "Waiting to download" else "Making episode available offline", .setContentText(if (progress == null) "Waiting to download" else "$progress %")
)
.setProgress(100, progress ?: 0, progress == null || progress == 0) .setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP)
.build() .build()
return ForegroundInfo( return ForegroundInfo(
stableNotificationId(id), 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() private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
companion object { companion object {
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total" const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads" 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. */ /** Shortest gap between two identical-looking progress reports. */
private const val PROGRESS_INTERVAL_MS = 1_000L private const val PROGRESS_INTERVAL_MS = 1_000L
@@ -186,4 +244,4 @@ internal class TransferRate {
} }
internal fun stableNotificationId(workId: UUID): Int = 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.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.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
onResume: (RetainedDownload) -> Unit, onResume: (RetainedDownload) -> Unit,
onDeletePartial: (RetainedDownload) -> Unit, onDeletePartial: (RetainedDownload) -> Unit,
padding: PaddingValues, padding: PaddingValues,
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
onOpen: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG) val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
) )
} }
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file -> 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. */ /** A saved episode: its still, what it is, its size, and a way to remove it. */
@Composable @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 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( ListItem(
modifier = if (title != null && index >= 0) {
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
} else {
Modifier
},
leadingContent = { leadingContent = {
RemoteArtwork( RemoteArtwork(
episode?.stillUrl ?: title?.backdropUrl, 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(" · ")) Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
}, },
trailingContent = { trailingContent = {
IconButton(onClick = onDelete) { Row {
Icon(Icons.Outlined.Delete, contentDescription = "Delete download") 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), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -329,6 +329,15 @@ fun ProtonStreamApp(
model::resumeDownload, model::resumeDownload,
model::deletePartial, model::deletePartial,
body, body,
onPlay = { title, index ->
playingTitleKey = title.key
playingIndex = index
playerMinimized = false
},
onOpen = { title ->
destination = Destination.Library
selectedTitleKey = title.key
},
) )
Destination.Settings -> SettingsScreen( Destination.Settings -> SettingsScreen(
state, 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. // on a form that has done its job.
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) }) SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
case .downloads: case .downloads:
DownloadsView() DownloadsView(onPlay: play, onOpen: { title in
destination = .library
selectedTitleKey = title.key
})
case .settings: case .settings:
SettingsView() SettingsView()
} }
@@ -19,6 +19,11 @@ final class DownloadCoordinator {
/// Every retained download, sorted by label. Re-read after each write. /// Every retained download, sorted by label. Re-read after each write.
private(set) var records: [RetainedDownload] = [] 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. /// A download finished or was removed: the catalog has changed.
@ObservationIgnored var onFinished: (() -> Void)? @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 /// 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. /// before the setting was turned on must not then download over cellular.
func applyNetworkPolicy() { func applyNetworkPolicy() {
queueReason = switch path?.status {
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
default: "Waiting for a connection"
}
if allowed { if allowed {
resumeQueued() resumeQueued()
} else { } 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 /// 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
func downloadStatusLine(_ download: RetainedDownload) -> String { /// `waitingFor`, what it is really waiting on.
var parts = [download.status.prefix(1).uppercased() + download.status.dropFirst()] 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 { if download.total > 0, download.downloaded > 0 {
parts.append("\(formatBytes(UInt64(download.downloaded))) of \(formatBytes(UInt64(download.total)))") 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 { struct DownloadsView: View {
var onPlay: (TitleRecord, Int) -> Void = { _, _ in }
var onOpen: (TitleRecord) -> Void = { _ in }
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
// Deleting a saved episode asks first: it is the viewer's own choice to // 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 }))" 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 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 { private struct SavedRow: View {
let file: OfflineRecord let file: OfflineRecord
let title: TitleRecord? let title: TitleRecord?
let onPlay: (TitleRecord, Int) -> Void
let onOpen: (TitleRecord) -> Void
let onDelete: () -> Void let onDelete: () -> Void
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
var body: some View { var body: some View {
let episode = file.episode 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) { HStack(spacing: 16) {
RemoteArtwork(episode?.stillUrl ?? title?.backdropUrl, title?.name ?? file.linkId, fallback: episode?.thumbnailSource, labelled: episode == nil) RemoteArtwork(episode?.stillUrl ?? title?.backdropUrl, title?.name ?? file.linkId, fallback: episode?.thumbnailSource, labelled: episode == nil)
.frame(width: 96) .frame(width: 96)
@@ -118,11 +125,21 @@ private struct SavedRow: View {
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) .textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
if let title {
IconButton("info.circle", "Open \(title.displayName)") { onOpen(title) }
}
IconButton("trash", "Delete download", action: onDelete) IconButton("trash", "Delete download", action: onDelete)
} }
.padding(.leading, 16) .padding(.leading, 16)
.padding(.trailing, 4) .padding(.trailing, 4)
.padding(.vertical, 8) .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) { HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(download.label).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(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 { if download.total > 0 {
AccentProgress(progress: Double(download.downloaded) / Double(download.total)).padding(.top, 8) 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 { if let active = download, active.total > 0, !episode.offline {
AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary) AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary)
.padding(.top, 8) .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) .padding(.horizontal, 16)