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:
nvrl committed 2026-10-07 00:32:03 +02:00
1 parent da16ab0575
commit ed05a1e723
38 files changed
+7546 -208

No files matched your search

+614
View File
@@ -0,0 +1,614 @@
import SwiftUI
import UIKit
/// How long the controls stay up after a tap.
private let controlsSeconds = 4.0
/// The narrowest layout the in-app volume slider is worth the room it costs.
/// Below it — a phone held upright — the hardware volume buttons are the
/// better answer.
private let volumeSliderMinWidth: CGFloat = 600
/// The rates worth offering. A short list rather than a slider: the useful
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
/// The player, over the whole window, notch included.
///
/// The episode itself lives in `PlayerHost`, so this view can go — minimised to
/// the mini transport, or with the app in the background — while it plays on.
struct PlayerView: View {
let host: PlayerHost
let player: MpvPlayer
@Environment(\.scheme) private var scheme
@State private var showControls = true
@State private var hideTask: Task<Void, Never>?
@State private var landscapeLocked = OrientationLock.landscape
@State private var sheet: PlayerSheet?
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black.ignoresSafeArea()
MpvView(player: player).ignoresSafeArea()
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture { setControls(!showControls) }
VStack(spacing: 0) {
if showControls { topBar.transition(.opacity) }
Spacer(minLength: 0)
if showControls {
PlayerControls(
host: host,
player: player,
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
landscapeLocked: landscapeLocked,
onRotate: { landscapeLocked = OrientationLock.toggle() },
onSheet: { sheet = $0 },
onInteract: { setControls(true) }
)
.transition(.opacity)
}
}
// Drawn whether or not the rest of the controls are up: an
// opening lasts ninety seconds, and a button you have to wake
// with a tap is one nobody reaches in time.
VStack(alignment: .trailing, spacing: 8) {
Spacer()
if let offer = host.offer, !host.autoSkip {
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
}
UpNextCard(
next: host.next,
visible: host.autoplay && !player.paused && host.chapters.creditsStart.map { player.position >= $0 } == true,
onPlayNow: host.advance
)
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 16)
.padding(.bottom, showControls ? 132 : 24)
// Only while nothing else explains the still picture: an error
// is the more useful thing to say.
if player.stalled && host.playbackError == nil {
VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(scheme.primary)
if player.buffering {
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
}
}
.padding(20)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
}
if let message = host.playbackError ?? player.problem {
VStack(spacing: 4) {
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
// Dismissible, because the message is not always fatal
// — a subtitle track that failed to load leaves an
// episode that plays perfectly well behind it.
Button("Dismiss", action: host.dismissProblem)
.buttonStyle(.quiet)
.tint(scheme.onErrorContainer)
}
.textStyle(.bodyLarge)
.padding(16)
.background(scheme.errorContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
.padding(32)
}
}
}
.animation(.easeInOut(duration: 0.2), value: showControls)
.statusBarHidden(!showControls)
.persistentSystemOverlays(.hidden)
.onAppear { setControls(true) }
.onChange(of: player.paused) { setControls(showControls) }
.sheet(item: $sheet) { which in
switch which {
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
case .episodes: EpisodeSheet(episodes: host.episodes, playing: host.index, onSelect: host.jump)
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
case let .tracks(type):
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
}
}
}
private var topBar: some View {
HStack(spacing: 0) {
// Two ways out, because they mean different things: this leaves the
// video and keeps the episode playing, ✕ ends it.
IconButton("chevron.down", "Leave the video", tint: .white) {
// The rest of the app is not a landscape app.
landscapeLocked = false
OrientationLock.set(false)
host.minimized = true
}
// The episode leads and the show follows it: which episode is the
// thing a viewer who has just woken the controls is checking.
VStack(alignment: .leading, spacing: 0) {
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
Text([host.title?.displayName, host.episode?.detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
IconButton("xmark", "Stop playback", tint: .white, action: host.close)
if player.duration > 0, player.duration - player.position >= 1 {
Text("\(clock(player.duration - player.position)) left")
.textStyle(.labelMedium).foregroundStyle(.white.opacity(0.7))
.padding(.leading, 12).padding(.trailing, 4)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
}
/// Show or hide the controls; shown and playing, they hide themselves.
private func setControls(_ shown: Bool) {
showControls = shown
hideTask?.cancel()
guard shown, !player.paused else { return }
hideTask = Task {
try? await Task.sleep(for: .seconds(controlsSeconds))
if !Task.isCancelled { showControls = false }
}
}
}
enum PlayerSheet: Identifiable {
case speed, episodes, chapters
case tracks(String)
var id: String {
switch self {
case .speed: "speed"
case .episodes: "episodes"
case .chapters: "chapters"
case let .tracks(type): "tracks-\(type)"
}
}
}
/// Everything the transport offers, in two rows: the seek bar on its own,
/// then the transport cluster on the left, where a thumb already is, and the
/// pickers on the right.
private struct PlayerControls: View {
let host: PlayerHost
let player: MpvPlayer
let roomForVolume: Bool
let landscapeLocked: Bool
let onRotate: () -> Void
let onSheet: (PlayerSheet) -> Void
let onInteract: () -> Void
@Environment(\.scheme) private var scheme
// Where the thumb is while a finger is on it: a seek per drag step is a
// block fetch per drag step, and the thumb would snap back under the
// finger between readings.
@State private var scrubbing: Double?
@State private var dragVolume: Double?
var body: some View {
let duration = max(player.duration, 1)
VStack(spacing: 0) {
HStack(spacing: 8) {
Text(clock(scrubbing ?? player.position)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
AccentTrack(
value: Binding(
get: { scrubbing ?? min(max(player.position, 0), duration) },
set: { scrubbing = $0 }
),
range: 0 ... duration,
buffered: player.duration > 0 ? player.cachedUntil / player.duration : 0,
ticks: player.duration > 0 && host.chapters.entries.count > 1
? host.chapters.entries.dropFirst().map { $0.start / player.duration } : [],
onEditing: { editing in
onInteract()
if !editing, let target = scrubbing {
host.seek(to: target)
scrubbing = nil
}
}
)
Text(clock(player.duration)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
}
HStack(spacing: 0) {
CompactIcon("backward.end.fill", "Previous episode", enabled: host.index > 0, action: host.retreat)
CompactIcon("gobackward.10", "Back ten seconds") { host.seek(to: player.position - 10) }
Button { host.setPaused(!player.paused); onInteract() } label: {
Image(systemName: player.paused ? "play.fill" : "pause.fill")
.font(.system(size: 30))
.foregroundStyle(.white)
.frame(width: 48, height: 48)
}
.accessibilityLabel(player.paused ? "Play" : "Pause")
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
Spacer(minLength: 4)
if host.episodes.count > 1 {
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
}
if host.chapters.entries.count > 1 {
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
}
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", "Mute") {
player.setMuted(!player.muted)
host.rememberVolume(muted: !player.muted)
}
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next.
if roomForVolume {
Slider(
value: Binding(
get: { dragVolume ?? min(max(player.volume, 0), 100) },
set: { dragVolume = $0; player.setVolume($0) }
),
in: 0 ... 100
) { editing in
onInteract()
if !editing, let chosen = dragVolume {
dragVolume = nil
host.rememberVolume(volume: chosen)
}
}
.tint(scheme.primary)
.frame(width: 96)
}
CompactIcon(
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
action: onRotate
)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
}
}
private struct CompactIcon: View {
let icon: String
let label: String
var enabled = true
let action: () -> Void
init(_ icon: String, _ label: String, enabled: Bool = true, action: @escaping () -> Void) {
self.icon = icon
self.label = label
self.enabled = enabled
self.action = action
}
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 19))
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(label)
}
}
/// The countdown into the next episode, shown once the credits run starts.
///
/// "Watch till the end" holds for the rest of the file: someone who wants the
/// post-credits scene should have to say so once, not once every ten seconds.
private struct UpNextCard: View {
let next: EpisodeRecord?
let visible: Bool
let onPlayNow: () -> Void
@Environment(\.scheme) private var scheme
@State private var held = false
@State private var remaining = upNextSeconds
@State private var heldFor: String?
var body: some View {
let showing = visible && next != nil && !(held && heldFor == next?.linkId)
Group {
if showing, let upNext = next {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 12) {
// The next episode's still, so the card says which
// episode without being read; the ring over it is both
// the countdown and the button that skips it.
Button(action: onPlayNow) {
RemoteArtwork(upNext.stillUrl, upNext.label, fallback: upNext.thumbnailSource, labelled: false)
.frame(width: 112)
.aspectRatio(16 / 9, contentMode: .fit)
.overlay {
ZStack {
Circle().fill(.black.opacity(0.5))
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
Circle()
.trim(from: 0, to: Double(remaining) / Double(upNextSeconds))
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: remaining)
Image(systemName: "play.fill").foregroundStyle(.white)
}
.frame(width: 40, height: 40)
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
}
.buttonStyle(.plain)
.accessibilityLabel("Play now")
VStack(alignment: .leading, spacing: 2) {
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
}
}
Button("Watch till the end") {
held = true
heldFor = upNext.linkId
}
.buttonStyle(.quiet)
}
.padding(12)
.frame(width: 280)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
.task(id: upNext.linkId) {
remaining = upNextSeconds
while remaining > 0 {
try? await Task.sleep(for: .seconds(1))
if Task.isCancelled { return }
remaining -= 1
}
onPlayNow()
}
}
}
}
}
private struct SpeedSheet: View {
let current: Double
let onSelect: (Double) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
DialogSheet("Playback speed") {
ForEach(speeds, id: \.self) { rate in
Button {
onSelect(rate)
dismiss()
} label: {
Text((abs(rate - current) < 0.001 ? "✓ " : "") + (rate == 1 ? "Normal" : "\(rate.formatted())×"))
.frame(maxWidth: .infinity)
}
.buttonStyle(.edgedWide)
}
} buttons: {
Button("Close") { dismiss() }.buttonStyle(.tonal)
}
}
}
private struct ChapterSheet: View {
let chapters: [ChapterEntry]
let position: Double
let onSelect: (ChapterEntry) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
let current = chapters.lastIndex { position + 0.001 >= $0.start }
DialogSheet("Chapters") {
ForEach(Array(chapters.enumerated()), id: \.element.index) { position, chapter in
Button {
onSelect(chapter)
dismiss()
} label: {
Text((position == current ? "✓ " : "") + "\(clock(chapter.start)) \(chapter.label)")
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.edgedWide)
}
} buttons: {
Button("Close") { dismiss() }.buttonStyle(.tonal)
}
}
}
private struct TrackSheet: View {
let type: String
let tracks: [MpvTrack]
let onSelect: (MpvTrack?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
DialogSheet(type == "audio" ? "Audio track" : "Subtitle track") {
if type == "sub" {
Button {
onSelect(nil)
dismiss()
} label: {
Text(tracks.contains(where: \.selected) ? "Off" : "✓ Off").frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.edgedWide)
}
ForEach(tracks) { track in
Button {
onSelect(track)
dismiss()
} label: {
Text(track.selected ? "✓ \(track.label)" : track.label).frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.edgedWide)
}
} buttons: {
Button("Close") { dismiss() }.buttonStyle(.tonal)
}
}
}
/// The title's episodes, from the player, opened scrolled to the one playing
/// so the next few are in view. Each row has its still, its name, and whether
/// it is playing, watched or part way.
private struct EpisodeSheet: View {
let episodes: [EpisodeRecord]
let playing: Int
let onSelect: (Int) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.scheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, 24).padding(.vertical, 8)
ScrollViewReader { reader in
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(episodes.enumerated()), id: \.element.linkId) { position, episode in
row(position, episode).id(position)
}
}
.padding(.bottom, 24)
}
.onAppear { reader.scrollTo(max(playing - 1, 0), anchor: .top) }
}
}
.padding(.top, 20)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(scheme.surfaceContainerLow)
.presentationCornerRadius(Corner.sheet)
}
private func row(_ position: Int, _ episode: EpisodeRecord) -> some View {
let current = position == playing
return Button {
dismiss()
onSelect(position)
} label: {
HStack(spacing: 12) {
RemoteArtwork(episode.stillUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
.frame(width: 112)
.aspectRatio(16 / 9, contentMode: .fit)
.overlay(alignment: .bottom) {
if let progress = episode.progress, !episode.watched, !current { AccentProgress(progress: progress) }
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 2) {
Text(episode.providerName ?? episode.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
Text(current ? "Playing"
: episode.watched ? "\(episode.label) · Watched"
: episode.resumeAt != nil ? "\(episode.label) · Part way" : episode.label)
.textStyle(.bodySmall)
.foregroundStyle(current ? scheme.primary : scheme.onSurfaceVariant)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(current ? scheme.secondaryContainer : .clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// The bar that says playback is still going, and gets back to it.
///
/// Leaving the player does not stop it — the episode keeps playing — so there
/// has to be something on screen that says so and takes one tap to return to.
struct MiniTransport: View {
let host: PlayerHost
@Environment(\.scheme) private var scheme
var body: some View {
if let episode = host.episode, let title = host.title {
HStack(spacing: 0) {
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: episode.thumbnailSource)
.frame(width: 64)
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 0) {
Text(episode.label).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface).lineLimit(1)
Text(title.displayName).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
let paused = host.player?.paused ?? true
IconButton(paused ? "play.fill" : "pause.fill", paused ? "Play" : "Pause", tint: scheme.onSurface) {
host.setPaused(!paused)
}
IconButton("xmark", "Stop playback", tint: scheme.onSurface, action: host.close)
}
.padding(8)
.card()
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
.padding(.horizontal, 8)
.contentShape(Rectangle())
.onTapGesture { host.minimized = false }
}
}
}
/// The view libmpv draws into: its Metal layer, sized to this view.
struct MpvView: UIViewRepresentable {
let player: MpvPlayer
func makeUIView(context _: Context) -> LayerView {
LayerView(layer: player.layer)
}
func updateUIView(_: LayerView, context _: Context) {}
final class LayerView: UIView {
private let video: CALayer
init(layer video: CALayer) {
self.video = video
super.init(frame: .zero)
backgroundColor = .black
video.removeFromSuperlayer()
layer.addSublayer(video)
}
@available(*, unavailable)
required init?(coder _: NSCoder) { nil }
override func layoutSubviews() {
super.layoutSubviews()
CATransaction.begin()
CATransaction.setDisableActions(true)
video.frame = bounds
video.contentsScale = window?.screen.scale ?? UIScreen.main.scale
CATransaction.commit()
}
}
}
/// Lock to landscape, or hand rotation back to the device.
@MainActor
enum OrientationLock {
private(set) static var landscape = false
/// What the app delegate answers for every window.
static var mask: UIInterfaceOrientationMask { landscape ? .landscape : .all }
/// Reports which way it went.
static func toggle() -> Bool {
set(!landscape)
return landscape
}
static func set(_ locked: Bool) {
landscape = locked
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else { return }
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
}
}