Files
proton-stream/ios/ProtonStream/UI/Common.swift
T
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00

403 lines
14 KiB
Swift

import SwiftUI
import UIKit
/// A tab's page: its own name in a bar, over whatever the page holds.
///
/// The bar draws under the status bar itself; the mini transport's room at the
/// foot of the page is the shell's `safeAreaInset`, so scrolling content clears
/// it without being told.
struct TabPage<Content: View, Leading: View, Actions: View>: View {
let title: String
@ViewBuilder var leading: Leading
@ViewBuilder var actions: Actions
@ViewBuilder var content: Content
@Environment(\.scheme) private var scheme
init(
_ title: String,
@ViewBuilder leading: () -> Leading = { EmptyView() },
@ViewBuilder actions: () -> Actions = { EmptyView() },
@ViewBuilder content: () -> Content
) {
self.title = title
self.leading = leading()
self.actions = actions()
self.content = content()
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
leading
Text(title)
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.lineLimit(1)
.padding(.leading, Leading.self == EmptyView.self ? 16 : 4)
Spacer(minLength: 8)
actions
}
.frame(height: 56)
.padding(.trailing, 4)
content.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(scheme.background)
}
}
/// A 48pt icon target: what Material's `IconButton` is.
struct IconButton: View {
let systemName: String
let label: String
var tint: Color?
var enabled = true
let action: () -> Void
@Environment(\.scheme) private var scheme
init(_ systemName: String, _ label: String, tint: Color? = nil, enabled: Bool = true, action: @escaping () -> Void) {
self.systemName = systemName
self.label = label
self.tint = tint
self.enabled = enabled
self.action = action
}
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 20))
.frame(width: 48, height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.pressableIcon)
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
.disabled(!enabled)
.accessibilityLabel(label)
}
}
struct EmptyState: View {
let title: String
let body_: String
let action: (label: String, run: () -> Void)?
@Environment(\.scheme) private var scheme
init(_ title: String, _ body: String, action: (label: String, run: () -> Void)? = nil) {
self.title = title
body_ = body
self.action = action
}
var body: some View {
VStack(spacing: 2) {
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
if let action {
Button(action.label, action: action.run).buttonStyle(.accent).padding(.top, 16)
}
}
.multilineTextAlignment(.center)
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// A Material card: the container fill at the shared radius.
struct CardBackground: ViewModifier {
@Environment(\.scheme) private var scheme
var fill: Color?
func body(content: Content) -> some View {
content.background(fill ?? scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
}
}
extension View {
func card(_ fill: Color? = nil) -> some View { modifier(CardBackground(fill: fill)) }
}
/// One row of a list: a leading icon, a headline, a supporting line and a
/// trailing control — Material's `ListItem`.
struct ListRow<Trailing: View>: View {
var icon: String?
var iconTint: Color?
let headline: String
var supporting: String?
var enabled = true
@ViewBuilder var trailing: Trailing
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 16) {
if let icon {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundStyle(iconTint ?? scheme.onSurfaceVariant)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(headline).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
if let supporting {
Text(supporting).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
}
}
Spacer(minLength: 0)
trailing
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(minHeight: 56)
.opacity(enabled ? 1 : 0.38)
.contentShape(Rectangle())
}
}
extension ListRow where Trailing == EmptyView {
init(icon: String? = nil, iconTint: Color? = nil, headline: String, supporting: String? = nil, enabled: Bool = true) {
self.icon = icon
self.iconTint = iconTint
self.headline = headline
self.supporting = supporting
self.enabled = enabled
trailing = EmptyView()
}
}
/// A switch row: Material's `ListItem` with a trailing `Switch`.
struct SwitchRow: View {
let headline: String
var supporting: String?
@Binding var isOn: Bool
@Environment(\.scheme) private var scheme
var body: some View {
ListRow(headline: headline, supporting: supporting) {
Toggle("", isOn: $isOn).labelsHidden().tint(scheme.primary)
}
.onTapGesture { isOn.toggle() }
}
}
/// A form sheet in the place of Material's `AlertDialog`: a heading, what it
/// asks, and its buttons at the foot, in the dialog container colour.
struct DialogSheet<Content: View, Buttons: View>: View {
let title: String
@ViewBuilder var content: Content
@ViewBuilder var buttons: Buttons
@Environment(\.scheme) private var scheme
init(_ title: String, @ViewBuilder content: () -> Content, @ViewBuilder buttons: () -> Buttons) {
self.title = title
self.content = content()
self.buttons = buttons()
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(title).textStyle(.headlineSmall).foregroundStyle(scheme.onSurface)
ScrollView {
VStack(alignment: .leading, spacing: 12) { content }
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollBounceBehavior(.basedOnSize)
HStack(spacing: 8) {
Spacer()
buttons
}
}
.padding(24)
.foregroundStyle(scheme.onSurfaceVariant)
.textStyle(.bodyMedium)
.presentationDetents([.medium, .large])
.presentationBackground(scheme.surfaceContainerHigh)
.presentationCornerRadius(Corner.sheet)
.environment(\.colorScheme, scheme.colorScheme)
}
}
/// A text field in the outlined Material style.
struct Field: View {
let label: String
@Binding var text: String
var secret = false
var keyboard: UIKeyboardType = .default
var content: UITextContentType?
var submit: SubmitLabel = .done
@Environment(\.scheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
Group {
if secret {
SecureField("", text: $text)
} else {
TextField("", text: $text)
}
}
.textStyle(.bodyLarge)
.foregroundStyle(scheme.onSurface)
.keyboardType(keyboard)
.textContentType(content)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(submit)
.padding(.horizontal, 12)
.frame(height: 48)
.overlay(RoundedRectangle(cornerRadius: Corner.tight).strokeBorder(scheme.outline))
}
}
}
/// Covers a sheet holding a secret while the app is not frontmost, so the app
/// switcher's snapshot does not keep it — what `FLAG_SECURE` does on Android.
struct SecureContent: ViewModifier {
@Environment(\.scenePhase) private var phase
@Environment(\.scheme) private var scheme
func body(content: Content) -> some View {
content.overlay {
if phase != .active {
scheme.surfaceContainerHigh.ignoresSafeArea()
}
}
}
}
extension View {
func secureContent() -> some View { modifier(SecureContent()) }
}
/// Where Play on a title lands: what Continue watching offers
/// (`TitleRecord.continueIndex`), else the most recently played part-watched
/// episode, else the first unwatched one, else the first — the desktop's
/// `next_up`.
func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> Int {
if let index = continueIndex.map(Int.init), playlist.indices.contains(index) {
return index
}
if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) {
return resumed
}
return playlist.firstIndex { !$0.watched } ?? 0
}
/// Episodes asked for offline at once, waiting on a yes.
struct DownloadRequest: Identifiable {
let id = UUID()
let episodes: [EpisodeRecord]
let what: String
}
/// Download episodes, or return the question to ask first when it is more
/// than one: a season or a show is tens of gigabytes, and one tap should not
/// fill a phone. Show the question with `confirmingDownload`.
@MainActor func download(_ episodes: [EpisodeRecord], _ what: String) -> DownloadRequest? {
let wanted = episodes.filter { !$0.offline }
if wanted.count > 1 { return DownloadRequest(episodes: wanted, what: what) }
DownloadCoordinator.shared.enqueue(wanted)
return nil
}
extension View {
/// Ask about a `download` of several episodes, with how many and how big.
func confirmingDownload(_ request: Binding<DownloadRequest?>) -> some View {
alert(
request.wrappedValue.map { "Download \($0.episodes.count) episodes of \($0.what)?" } ?? "",
isPresented: Binding(get: { request.wrappedValue != nil }, set: { if !$0 { request.wrappedValue = nil } }),
presenting: request.wrappedValue
) { asked in
Button("Download") { DownloadCoordinator.shared.enqueue(asked.episodes) }
Button("Cancel", role: .cancel) {}
} message: { asked in
let bytes = asked.episodes.reduce(UInt64(0)) { $0 + ($1.size ?? 0) }
Text("They are kept on this device for watching without a connection\(bytes > 0 ? " — about \(formatBytes(bytes))" : ""). Delete them from Downloads.")
}
}
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
func formatRuntime(_ minutes: UInt32) -> String {
let hours = minutes / 60, rest = minutes % 60
if hours == 0 { return "\(rest) min" }
if rest == 0 { return "\(hours) h" }
return "\(hours) h \(rest) min"
}
func formatBytes(_ bytes: UInt64) -> String {
let value = Double(bytes)
let gib = 1024.0 * 1024 * 1024
let mib = 1024.0 * 1024
if value >= gib { return String(format: "%.1f GiB", value / gib) }
if value >= mib { return String(format: "%.1f MiB", value / mib) }
if value >= 1024 { return String(format: "%.1f KiB", value / 1024) }
return "\(bytes) bytes"
}
/// One line under a download that has not finished: "Running · 120.0 MiB of
/// 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
/// `waitingFor`, what it is really waiting on.
func downloadStatusLine(_ download: RetainedDownload, waitingFor: String? = nil) -> String {
let status = download.status == RetainedDownload.statusQueued ? waitingFor : nil
var parts = [status ?? download.status.prefix(1).uppercased() + download.status.dropFirst()]
if download.total > 0, download.downloaded > 0 {
parts.append("\(formatBytes(UInt64(download.downloaded))) of \(formatBytes(UInt64(download.total)))")
}
if download.bytesPerSecond > 0, download.status == RetainedDownload.statusRunning {
parts.append("\(formatBytes(UInt64(download.bytesPerSecond)))/s")
}
return parts.joined(separator: " · ")
}
extension EpisodeRecord {
/// The episode's own name: the provider's, else the one the filename gave
/// it. `nil` rather than the filename, which is a release group's tag and
/// the episode's numbering again.
var shownName: String? {
providerName ?? (detail != name && !detail.isEmpty ? detail : nil)
}
/// What an episode row is headed with: "1. Mother and Children".
///
/// The provider's name for the episode, else the name the filename gave it,
/// else "Episode 1". The bare filename only when none of those exist.
func heading(_ position: Int) -> String {
let named = shownName
switch (named, number) {
case let (named?, number?): return "\(number). \(named)"
case let (named?, nil): return named
case let (nil, number?): return "Episode \(number)"
case (nil, nil):
let stem = name.range(of: ".", options: .backwards).map { String(name[..<$0.lowerBound]) } ?? name
return stem.trimmingCharacters(in: .whitespaces).isEmpty ? "Episode \(position + 1)" : stem
}
}
}
extension TitleRecord {
/// Every episode, in display order — what the player is handed.
var playlist: [EpisodeRecord] { seasons.flatMap(\.episodes) }
}
/// Colours mixed in sRGB, for the few places a role is blended with another.
func mix(_ from: Color, _ to: Color, _ fraction: Double) -> Color {
let a = UIColor(from).rgba
let b = UIColor(to).rgba
let t = min(max(fraction, 0), 1)
return Color(
.sRGB,
red: a.0 + (b.0 - a.0) * t,
green: a.1 + (b.1 - a.1) * t,
blue: a.2 + (b.2 - a.2) * t,
opacity: a.3 + (b.3 - a.3) * t
)
}
private extension UIColor {
var rgba: (Double, Double, Double, Double) {
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return (red, green, blue, alpha)
}
}