Files
proton-stream/ios/ProtonStream/UI/LanguagePicker.swift
T
nvrl ed05a1e723 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.
2026-10-07 00:32:03 +02:00

121 lines
5.3 KiB
Swift

import SwiftUI
/// The languages offered by name, as the three-letter tag a Matroska file
/// carries.
///
/// The same short list the desktop names tracks from (`pstr-player`'s
/// `language_name`). mpv treats the two- and three-letter forms of a language
/// as one, so the tag chosen here also matches a file that says "ja". Anything
/// else is still one typed tag away.
let languages: [(code: String, name: String)] = [
("jpn", "Japanese"), ("eng", "English"), ("ger", "German"), ("fre", "French"),
("spa", "Spanish"), ("ita", "Italian"), ("por", "Portuguese"), ("dut", "Dutch"),
("rus", "Russian"), ("pol", "Polish"), ("swe", "Swedish"), ("dan", "Danish"),
("nor", "Norwegian"), ("fin", "Finnish"), ("cze", "Czech"), ("hun", "Hungarian"),
("tur", "Turkish"), ("ara", "Arabic"), ("heb", "Hebrew"), ("hin", "Hindi"),
("kor", "Korean"), ("chi", "Chinese"), ("tha", "Thai"), ("vie", "Vietnamese"),
("ukr", "Ukrainian"), ("rum", "Romanian"), ("gre", "Greek"), ("ind", "Indonesian"),
("may", "Malay"),
]
/// ISO 639-1, the form a file or the desktop may also have stored.
let twoLetter: [String: String] = [
"jpn": "ja", "eng": "en", "ger": "de", "fre": "fr", "spa": "es", "ita": "it",
"por": "pt", "dut": "nl", "rus": "ru", "pol": "pl", "swe": "sv", "dan": "da",
"nor": "no", "fin": "fi", "cze": "cs", "hun": "hu", "tur": "tr", "ara": "ar",
"heb": "he", "hin": "hi", "kor": "ko", "chi": "zh", "tha": "th", "vie": "vi",
"ukr": "uk", "rum": "ro", "gre": "el", "ind": "id", "may": "ms",
]
/// ISO 639-2/T, for the handful whose bibliographic code differs.
private let terminology: [String: String] = [
"ger": "deu", "fre": "fra", "dut": "nld", "cze": "ces", "chi": "zho", "rum": "ron", "gre": "ell", "may": "msa",
]
/// "Japanese", for a tag in any of the forms a file or the desktop may have
/// stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
/// Nil is no preference.
func languageLabel(_ tag: String?) -> String {
guard let value = tag?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return "No preference" }
return languageOf(value)?.name ?? value
}
/// The listed language a tag names, matching either ISO 639 form.
private func languageOf(_ tag: String) -> (code: String, name: String)? {
let base = tag.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map { $0.lowercased() } ?? ""
return languages.first { $0.code == base || twoLetter[$0.code] == base || terminology[$0.code] == base }
}
/// A language preference as a row, opening a list to choose from.
struct LanguageSetting: View {
let headline: String
let value: String?
let onChange: (String?) -> Void
@State private var choosing = false
var body: some View {
Button { choosing = true } label: {
ListRow(headline: headline, supporting: languageLabel(value))
}
.buttonStyle(.plain)
.sheet(isPresented: $choosing) {
LanguageSheet(headline: headline, value: value) { tag in
choosing = false
onChange(tag)
}
}
}
}
private struct LanguageSheet: View {
let headline: String
let onChoose: (String?) -> Void
private let current: String?
@State private var typed: String
@Environment(\.dismiss) private var dismiss
@Environment(\.scheme) private var scheme
init(headline: String, value: String?, onChoose: @escaping (String?) -> Void) {
self.headline = headline
self.onChoose = onChoose
let current = value.flatMap { languageOf($0)?.code ?? $0 }
self.current = current
// A tag that is not on the list starts in the field, so it is shown and
// can be edited rather than silently replaced.
_typed = State(initialValue: current.flatMap { tag in languages.contains { $0.code == tag } ? nil : tag } ?? "")
}
var body: some View {
DialogSheet(headline) {
option("No preference", selected: current == nil) { onChoose(nil) }
ForEach(languages, id: \.code) { language in
option(language.name, selected: current == language.code) { onChoose(language.code) }
}
Divider().overlay(scheme.outlineVariant).padding(.vertical, 8)
Field(label: "Another tag", text: $typed)
Text("As it appears in the file, such as \"tgl\"").textStyle(.bodySmall)
} buttons: {
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
Button("Use tag") { onChoose(typed.trimmingCharacters(in: .whitespaces).lowercased()) }
.buttonStyle(.accent)
.disabled(typed.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
private func option(_ name: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.font(.system(size: 20))
.foregroundStyle(selected ? scheme.primary : scheme.onSurfaceVariant)
Text(name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
Spacer()
}
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}