diff --git a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt index 2b80881..d9de12e 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt @@ -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) 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 8ef1c3d..6caeec8 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 @@ -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), diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index e8b8211..0f9fd11 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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, diff --git a/android/app/src/test/screenshots/downloads-phone.png b/android/app/src/test/screenshots/downloads-phone.png index 1a2860a..28dec5a 100644 Binary files a/android/app/src/test/screenshots/downloads-phone.png and b/android/app/src/test/screenshots/downloads-phone.png differ diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index 13ea33f..a370d13 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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() } diff --git a/ios/ProtonStream/Download/DownloadCoordinator.swift b/ios/ProtonStream/Download/DownloadCoordinator.swift index a919d68..b5103dc 100644 --- a/ios/ProtonStream/Download/DownloadCoordinator.swift +++ b/ios/ProtonStream/Download/DownloadCoordinator.swift @@ -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 { diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 9c0f8fb..7c54e4c 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -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)))") } diff --git a/ios/ProtonStream/UI/DownloadsView.swift b/ios/ProtonStream/UI/DownloadsView.swift index 22bb341..4cc5e8f 100644 --- a/ios/ProtonStream/UI/DownloadsView.swift +++ b/ios/ProtonStream/UI/DownloadsView.swift @@ -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) } diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 8044b1a..f11609f 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -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)