Files
proton-stream/ios/ProtonStream/UI/DownloadsView.swift
T
nvrl 5b931111b3 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".
2026-10-13 15:48:59 +02:00

180 lines
8.2 KiB
Swift

import SwiftUI
/// One show's saved episodes, and the show itself where the library has it.
private struct OfflineGroup: Identifiable {
let name: String
let title: TitleRecord?
let files: [OfflineRecord]
var id: String { name }
}
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
// keep it, and getting it back is a download, not a tap.
@State private var deleting: OfflineRecord?
var body: some View {
let retained = DownloadCoordinator.shared.records
let offline = model.offline
TabPage("Downloads") {
if retained.isEmpty && offline.isEmpty {
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if !offline.isEmpty {
Text("\(offline.count) \(offline.count == 1 ? "episode" : "episodes") · \(formatBytes(offline.reduce(0) { $0 + $1.size })) on this device")
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurfaceVariant)
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
// What is still arriving comes first: it is the part of
// the page that changes, and the part with something to do.
if !retained.isEmpty {
SectionHeading(title: "In progress")
ForEach(retained) { download in
PartialRow(download: download)
}
}
// Under the show they belong to, as on desktop: a saved
// season is fourteen rows, and fourteen rows of
// "Episode 3" name nothing.
ForEach(groups(offline)) { group in
SectionHeading(
title: group.name,
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, onPlay: onPlay, onOpen: onOpen) { deleting = file }
}
}
}
.padding(.bottom, 24)
}
}
}
.alert(
"Delete this download?",
isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
presenting: deleting
) { file in
Button("Delete", role: .destructive) { model.removeOffline(file) }
Button("Cancel", role: .cancel) {}
} message: { file in
Text("\(file.episode?.label ?? "The episode") (\(formatBytes(file.size))) leaves this device. It still streams, and can be downloaded again.")
}
}
/// Which show each saved file is from. The offline record knows its
/// episode but not its show, and the library is the only thing that does.
private func groups(_ offline: [OfflineRecord]) -> [OfflineGroup] {
var shows: [String: TitleRecord] = [:]
for title in model.titles {
for episode in title.playlist { shows["\(episode.shareId)/\(episode.linkId)"] = title }
}
let grouped = Dictionary(grouping: offline) { shows["\($0.shareId)/\($0.linkId)"]?.key }
return grouped.map { key, files in
let title = key.flatMap { key in model.titles.first { $0.key == key } }
return OfflineGroup(name: title?.displayName ?? "Not in the library", title: title, files: files)
}
.sorted { $0.name < $1.name }
}
}
private struct SectionHeading: View {
let title: String
var caption: String?
@Environment(\.scheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface).lineLimit(1)
if let caption { Text(caption).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant) }
}
.padding(EdgeInsets(top: 20, leading: 16, bottom: 4, trailing: 16))
}
}
/// A saved episode: its still, what it is, its size, and a way to remove it.
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)
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 2) {
Text(episode?.heading(0) ?? file.linkId).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
Text([episode?.label, formatBytes(file.size)].compactMap { $0 }.joined(separator: " · "))
.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) }
}
}
}
/// A download not yet finished: how far it has got, why it stopped if it did,
/// and pause or resume beside cancel.
private struct PartialRow: View {
let download: RetainedDownload
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
let active = download.status == RetainedDownload.statusRunning || download.status == RetainedDownload.statusQueued
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(download.label).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
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)
}
if let error = download.error {
Text(error).textStyle(.bodyMedium).foregroundStyle(scheme.error)
}
}
Spacer(minLength: 0)
if active {
IconButton("pause.fill", "Pause download") { model.pauseDownload(download) }
} else {
IconButton("play.fill", "Resume download") { model.resumeDownload(download) }
}
// What has arrived so far is discarded; nothing finished is.
IconButton("xmark", "Cancel and delete what has arrived") { model.deletePartial(download) }
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.vertical, 8)
}
}