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,174 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The four buttons the Android client draws, at the shared corner radius.
|
||||
///
|
||||
/// The primary button wears the accent ramp; a disabled one keeps a flat
|
||||
/// disabled fill, because a greyed control that still wears the one strong
|
||||
/// colour in the window reads as pressable.
|
||||
enum ButtonKind {
|
||||
case accent, tonal, edged, quiet
|
||||
}
|
||||
|
||||
struct ProtonButtonStyle: ButtonStyle {
|
||||
let kind: ButtonKind
|
||||
var expand = false
|
||||
@Environment(\.scheme) private var scheme
|
||||
@Environment(\.isEnabled) private var enabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.textStyle(.labelLarge)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, kind == .quiet ? 12 : 24)
|
||||
.frame(maxWidth: expand ? .infinity : nil)
|
||||
// 40pt drawn, inside the 48pt a finger is given.
|
||||
.frame(minHeight: 40)
|
||||
.foregroundStyle(foreground)
|
||||
.background { fill }
|
||||
.overlay {
|
||||
if kind == .edged {
|
||||
RoundedRectangle(cornerRadius: Corner.standard)
|
||||
.strokeBorder(enabled ? scheme.outline : scheme.onSurface.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.opacity(configuration.isPressed ? 0.8 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
guard enabled else { return scheme.onSurface.opacity(0.38) }
|
||||
switch kind {
|
||||
case .accent: return scheme.onPrimary
|
||||
case .tonal: return scheme.onSecondaryContainer
|
||||
case .edged, .quiet: return scheme.primary
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var fill: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Corner.standard)
|
||||
switch kind {
|
||||
case .accent:
|
||||
if enabled { shape.fill(scheme.accentGradient) } else { shape.fill(scheme.onSurface.opacity(0.12)) }
|
||||
case .tonal:
|
||||
shape.fill(enabled ? scheme.secondaryContainer : scheme.onSurface.opacity(0.12))
|
||||
case .edged, .quiet:
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == ProtonButtonStyle {
|
||||
static var accent: ProtonButtonStyle { ProtonButtonStyle(kind: .accent) }
|
||||
static var accentWide: ProtonButtonStyle { ProtonButtonStyle(kind: .accent, expand: true) }
|
||||
static var tonal: ProtonButtonStyle { ProtonButtonStyle(kind: .tonal) }
|
||||
static var edged: ProtonButtonStyle { ProtonButtonStyle(kind: .edged) }
|
||||
static var edgedWide: ProtonButtonStyle { ProtonButtonStyle(kind: .edged, expand: true) }
|
||||
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
|
||||
}
|
||||
|
||||
/// How far along something is, drawn in the accent.
|
||||
///
|
||||
/// `solid` is for the one bar that is deliberately not the accent: a download in
|
||||
/// flight sits directly under the watch-progress bar of the same row, and two
|
||||
/// ramps in the same colour stacked two points apart say nothing.
|
||||
struct AccentProgress: View {
|
||||
let progress: Double
|
||||
var solid: Color?
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let height = geometry.size.height
|
||||
let done = geometry.size.width * min(max(progress, 0), 1)
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(scheme.surfaceContainerHighest)
|
||||
// Below one bar's width there is nothing to round.
|
||||
if done >= height {
|
||||
if let solid {
|
||||
Capsule().fill(solid).frame(width: done)
|
||||
} else {
|
||||
Capsule().fill(scheme.accentGradient).frame(width: done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// The seek bar, in the accent ramp, with what is already read ahead shaded
|
||||
/// under the played part: a jump that lands in it costs no fetch.
|
||||
///
|
||||
/// `onCommit` fires once, when the finger lifts — a seek per drag step is a
|
||||
/// block fetch per drag step.
|
||||
struct AccentTrack: View {
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
var buffered: Double = 0
|
||||
var ticks: [Double] = []
|
||||
var onEditing: (Bool) -> Void = { _ in }
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var dragging = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let span = max(range.upperBound - range.lowerBound, 0.001)
|
||||
let fraction = min(max((value - range.lowerBound) / span, 0), 1)
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.white.opacity(0.30)).frame(height: 6)
|
||||
if width * buffered >= 6 {
|
||||
Capsule().fill(Color.white.opacity(0.55)).frame(width: width * min(max(buffered, 0), 1), height: 6)
|
||||
}
|
||||
if width * fraction >= 6 {
|
||||
Capsule().fill(scheme.accentGradient).frame(width: width * fraction, height: 6)
|
||||
}
|
||||
ForEach(ticks.indices, id: \.self) { index in
|
||||
let tick = min(max(ticks[index], 0), 1)
|
||||
Rectangle().fill(Color.black.opacity(0.6)).frame(width: 2, height: 6)
|
||||
.offset(x: width * tick - 1)
|
||||
}
|
||||
Circle().fill(scheme.primary)
|
||||
.frame(width: dragging ? 22 : 16, height: dragging ? 22 : 16)
|
||||
.offset(x: width * fraction - (dragging ? 11 : 8))
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
if !dragging {
|
||||
dragging = true
|
||||
onEditing(true)
|
||||
}
|
||||
let x = min(max(drag.location.x / max(width, 1), 0), 1)
|
||||
value = range.lowerBound + x * span
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragging = false
|
||||
onEditing(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 32)
|
||||
.accessibilityElement()
|
||||
.accessibilityValue(Text(clock(value)))
|
||||
.accessibilityAdjustableAction { direction in
|
||||
onEditing(true)
|
||||
value = min(max(value + (direction == .increment ? 10 : -10), range.lowerBound), range.upperBound)
|
||||
onEditing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds as `m:ss` or `h:mm:ss`.
|
||||
func clock(_ seconds: Double) -> String {
|
||||
let total = Int(max(seconds, 0).rounded(.down))
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
let rest = total % 60
|
||||
return hours > 0
|
||||
? String(format: "%d:%02d:%02d", hours, minutes, rest)
|
||||
: String(format: "%d:%02d", minutes, rest)
|
||||
}
|
||||
Reference in new issue
Block a user