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