mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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.
This commit is contained in:
1 parent
da16ab0575
commit
ed05a1e723
38 files changed
+7546
-208
No files matched your search
@@ -0,0 +1,162 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user