Files
proton-stream/ios/ProtonStream/UI/DownloadsView.swift
T
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
2026-10-07 00:32:03 +02:00

163 lines
7.4 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 {
@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) { 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 onDelete: () -> Void
@Environment(\.scheme) private var scheme
var body: some View {
let episode = file.episode
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)
IconButton("trash", "Delete download", action: onDelete)
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.vertical, 8)
}
}
/// 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)).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)
}
}