mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
142 lines
5.9 KiB
Swift
142 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
struct Played: Identifiable {
|
|
let title: TitleRecord
|
|
let episode: EpisodeRecord
|
|
let index: Int
|
|
|
|
var id: String { "\(episode.shareId)/\(episode.linkId)" }
|
|
}
|
|
|
|
/// Every episode started or finished, newest first — the desktop's
|
|
/// `Library::history`, over the records the library already holds.
|
|
///
|
|
/// A row marked unwatched with its position at zero is "never played" for the
|
|
/// purpose here, and left out. Progress stands in for the position: it is
|
|
/// non-zero exactly when the position is.
|
|
func history(_ titles: [TitleRecord]) -> [Played] {
|
|
titles.flatMap { title in
|
|
title.playlist.enumerated()
|
|
.filter { $0.element.watched || ($0.element.progress ?? 0) > 0 }
|
|
.map { Played(title: title, episode: $0.element, index: $0.offset) }
|
|
}
|
|
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
|
}
|
|
|
|
/// "Today", "Yesterday", a weekday within the last week, then a date — how the
|
|
/// history page heads each day.
|
|
func dayHeading(_ day: Date, today: Date, calendar: Calendar = .current, locale: Locale = .current) -> String {
|
|
let day = calendar.startOfDay(for: day)
|
|
let today = calendar.startOfDay(for: today)
|
|
let days = calendar.dateComponents([.day], from: day, to: today).day ?? 0
|
|
if days == 0 { return "Today" }
|
|
if days == 1 { return "Yesterday" }
|
|
let format = DateFormatter()
|
|
format.locale = locale
|
|
format.calendar = calendar
|
|
format.timeZone = calendar.timeZone
|
|
if days > 0, days < 7 {
|
|
format.dateFormat = "EEEE"
|
|
} else if calendar.component(.year, from: day) == calendar.component(.year, from: today) {
|
|
format.dateFormat = "d MMMM"
|
|
} else {
|
|
format.dateFormat = "d MMMM yyyy"
|
|
}
|
|
return format.string(from: day)
|
|
}
|
|
|
|
private struct PlayedDay {
|
|
let day: Date
|
|
var rows: [Played]
|
|
}
|
|
|
|
struct HistoryView: View {
|
|
let onPlay: (TitleRecord, Int) -> Void
|
|
let onTitle: (TitleRecord) -> Void
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
let calendar = Calendar.current
|
|
let played = history(model.titles)
|
|
// Grouped in order: the list is newest first, so each day's rows are
|
|
// already together.
|
|
let days = played.reduce(into: [PlayedDay]()) { days, entry in
|
|
let day = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(entry.episode.lastPlayed)))
|
|
if days.last?.day == day { days[days.count - 1].rows.append(entry) } else { days.append(PlayedDay(day: day, rows: [entry])) }
|
|
}
|
|
TabPage("History") {
|
|
if days.isEmpty {
|
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(days, id: \.day) { day in
|
|
Text(dayHeading(day.day, today: Date()))
|
|
.textStyle(.titleSmall)
|
|
.foregroundStyle(scheme.primary)
|
|
.padding(EdgeInsets(top: 20, leading: 16, bottom: 4, trailing: 16))
|
|
ForEach(day.rows) { entry in
|
|
HistoryRow(
|
|
entry: entry,
|
|
onPlay: { onPlay(entry.title, entry.index) },
|
|
onTitle: { onTitle(entry.title) },
|
|
onRemove: { model.removeFromHistory(entry.title, entry.episode) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The still plays the episode, the text opens its title, and the cross takes
|
|
/// it off the page — the same three targets the desktop row has.
|
|
private struct HistoryRow: View {
|
|
let entry: Played
|
|
let onPlay: () -> Void
|
|
let onTitle: () -> Void
|
|
let onRemove: () -> Void
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
let episode = entry.episode
|
|
HStack(spacing: 0) {
|
|
RemoteArtwork(episode.stillUrl ?? entry.title.backdropUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
|
.frame(width: 128)
|
|
.aspectRatio(16 / 9, contentMode: .fit)
|
|
.overlay(alignment: .bottom) {
|
|
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
|
.onTapGesture(perform: onPlay)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(entry.title.displayName).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(1)
|
|
Text(episode.providerName.map { "\(episode.label) · \($0)" } ?? episode.label)
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurface).lineLimit(1)
|
|
Text(episode.watched ? "Finished" : episode.stoppedAt)
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: onTitle)
|
|
IconButton("xmark", "Remove from history", action: onRemove)
|
|
}
|
|
.padding(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 4))
|
|
}
|
|
}
|
|
|
|
private extension EpisodeRecord {
|
|
/// "Stopped at 12:04 of 23:40", or as much of it as is known.
|
|
var stoppedAt: String {
|
|
guard let at = resumeAt else { return "Started" }
|
|
let duration = progress.flatMap { $0 > 0 ? at / $0 : nil }
|
|
return "Stopped at \(clock(at))" + (duration.map { " of \(clock($0))" } ?? "")
|
|
}
|
|
}
|