Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
import MediaKit
|
||||
|
||||
/// Die Einträge, die am Server etwas ändern — ohne Hülle darum.
|
||||
///
|
||||
/// **Gesehen, Favorit, Sterne gehören nicht nur auf die Detailseite.** Wer in
|
||||
/// einer Reihe steht und weiss, dass er die Folge schon kennt, will sie dort
|
||||
/// abhaken und nicht erst eine Seite tiefer gehen. Dieselben Einträge, zwei
|
||||
/// Orte — und deshalb an einer Stelle beschrieben.
|
||||
///
|
||||
/// **Warum sie keinen eigenen Zustand tragen.** Der Inhalt eines
|
||||
/// Kontextmenüs lebt nur, solange das Menü offen ist: SwiftUI baut ihn beim
|
||||
/// Öffnen und wirft ihn beim Schliessen weg. Ein `@State` darin könnte also
|
||||
/// nichts anzeigen, was **nach** dem Tippen passiert — weder Kreisel noch
|
||||
/// Fehlermeldung, denn da ist das Menü längst zu. Ausgeführt wird deshalb vom
|
||||
/// Aufrufer, der länger lebt als das Menü.
|
||||
///
|
||||
/// Aus demselben Grund kommen `capabilities` als Wert herein, statt aus der
|
||||
/// Umgebung gelesen zu werden: was in ein Kontextmenü durchgereicht wird, ist
|
||||
/// von SwiftUI-Fassung zu SwiftUI-Fassung verschieden — ein Wert, den der
|
||||
/// Aufrufer übergibt, ist es nicht.
|
||||
struct ItemActionEntries: View {
|
||||
let item: MediaItem
|
||||
let capabilities: Set<BackendCapability>
|
||||
/// Führt die Serveraktion aus und räumt hinterher auf.
|
||||
let run: (@escaping (any MediaBackend) async throws -> Void) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
run { backend in
|
||||
if item.isWatched {
|
||||
try await backend.markUnwatched(id: item.id)
|
||||
} else {
|
||||
try await backend.markWatched(id: item.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
item.isWatched ? "action.markUnwatched" : "action.markWatched",
|
||||
systemImage: item.isWatched ? "eye.slash" : "checkmark.circle"
|
||||
)
|
||||
}
|
||||
|
||||
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||
// Fehler bei sich.
|
||||
if item.watchProgress != nil {
|
||||
Button {
|
||||
run { try await $0.removeFromContinueWatching(item) }
|
||||
} label: {
|
||||
Label("action.removeFromContinue", systemImage: "minus.circle")
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.contains(.favourites) {
|
||||
Button {
|
||||
run { try await $0.setFavourite(!item.isFavourite, id: item.id) }
|
||||
} label: {
|
||||
Label(
|
||||
item.isFavourite ? "action.removeFavourite" : "action.addFavourite",
|
||||
systemImage: item.isFavourite ? "heart.slash" : "heart"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if capabilities.contains(.ratings) {
|
||||
Menu {
|
||||
ForEach((1...5).reversed(), id: \.self) { stars in
|
||||
Button {
|
||||
// In Zehnteln: fünf Sterne sind eine 10.
|
||||
run { try await $0.rate(id: item.id, rating: stars * 2) }
|
||||
} label: {
|
||||
Label(String(repeating: "★", count: stars), systemImage: "star")
|
||||
}
|
||||
}
|
||||
if item.rating != nil {
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
run { try await $0.rate(id: item.id, rating: 0) }
|
||||
} label: {
|
||||
Label("action.clearRating", systemImage: "star.slash")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("action.rate", systemImage: "star")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Was zu tun ist, wenn ein Titel am Server verändert wurde.
|
||||
///
|
||||
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
|
||||
/// in einer Reihe, die in der Startseite steckt — und nur die Startseite
|
||||
/// weiss, wie man ihre Reihen neu lädt. Diesen Rückweg durch jede
|
||||
/// Zwischenebene zu reichen hiesse, drei Ansichten um ein Argument zu
|
||||
/// erweitern, das sie nur weitergeben.
|
||||
///
|
||||
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
||||
/// auch nichts neu zu laden.
|
||||
@Entry var haloItemChanged: () async -> Void = {}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
||||
/// startet.
|
||||
///
|
||||
/// **Der Umweg, den es spart.** Wer weiß, was er sehen will, klickte bisher
|
||||
/// auf die Kachel, wartete auf die Detailseite und drückte dort „Abspielen".
|
||||
/// Zwei Schritte für eine Absicht.
|
||||
///
|
||||
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
||||
/// in `HaloDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
||||
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
||||
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
||||
/// und wäre für VoiceOver gar nicht mehr da.
|
||||
struct QuickPlayTile<Card: View>: View {
|
||||
let item: MediaItem
|
||||
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
|
||||
///
|
||||
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
|
||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
||||
/// Servers, keine Entscheidung des Nutzers.
|
||||
var opensDetails: Bool = false
|
||||
@ViewBuilder let card: () -> Card
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var isPointerInside = false
|
||||
@State private var errorMessage: String?
|
||||
/// Der nachgeladene Titel, sobald die Qualitätsauswahl offen sein soll.
|
||||
///
|
||||
/// **Zwei Dinge in einem Zustand**, und das mit Absicht: die Kachel kennt
|
||||
/// nur eine Zusammenfassung ohne Dateiangaben, also muss vor der Frage
|
||||
/// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter
|
||||
/// Zustand, der mit diesem gleichziehen müsste.
|
||||
@State private var downloadChoice: MediaItem?
|
||||
/// Stufe steht, Tonspur fehlt — siehe `DownloadButton`.
|
||||
@State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)?
|
||||
|
||||
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
||||
/// hat. Siehe `ItemActionEntries`.
|
||||
@Environment(\.haloItemChanged) private var itemChanged
|
||||
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
||||
@Environment(\.haloPush) private var push
|
||||
|
||||
private var isMac: Bool { HaloPlatform.isMac }
|
||||
|
||||
/// Ob es überhaupt etwas zu starten gibt.
|
||||
///
|
||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||
private var isPlayable: Bool { item.kind != .collection }
|
||||
|
||||
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||
|
||||
/// Wohin ein Tipp führt.
|
||||
///
|
||||
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
||||
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
||||
|
||||
var body: some View {
|
||||
kachel
|
||||
.buttonStyle(.plain)
|
||||
// **Als Überzug auf dem Link, nicht als Kind darin.**
|
||||
//
|
||||
// Ein `Button` im *Label* eines `NavigationLink` löst auf iOS nie aus:
|
||||
// der Link macht sein Label zu einer einzigen Fläche, verschachtelte
|
||||
// Bedienelemente werden nicht eigens getroffen — und `.plain` macht
|
||||
// das Versagen lautlos. Als Geschwister darüber gewinnt er.
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPlayable, isMac { playButton }
|
||||
}
|
||||
.onContinuousHover { phase in
|
||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||
guard isMac else { return }
|
||||
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
||||
}
|
||||
.animation(.easeOut(duration: 0.15), value: isPointerInside)
|
||||
// Langes Drücken am Telefon und am iPad, Rechtsklick am Mac.
|
||||
//
|
||||
// **Hier und nicht nur auf der Detailseite.** Eine Folge abzuhaken,
|
||||
// die man schon kennt, war bisher zwei Schritte weit weg: Kachel
|
||||
// antippen, warten, Menü oben rechts. Jetzt steht es dort, wo die
|
||||
// Kachel steht.
|
||||
.contextMenu {
|
||||
if isPlayable {
|
||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
||||
// Starten gibt es den Knopf beim Überfahren.
|
||||
if !tapOpensDetails {
|
||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||
Divider()
|
||||
}
|
||||
Button("tile.play", systemImage: "play.fill") { trigger() }
|
||||
|
||||
// **Herunterladen gehört hierher.** Bisher führte der Weg
|
||||
// über die Detailseite — zwei Schritte für etwas, das man
|
||||
// beim Vorbeiscrollen entscheidet.
|
||||
if isDownloadable {
|
||||
Button("downloads.download", systemImage: "arrow.down.circle") {
|
||||
Task { await prepareDownload() }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
ItemActionEntries(
|
||||
item: item,
|
||||
capabilities: session.backend?.capabilities ?? [],
|
||||
run: run
|
||||
)
|
||||
}
|
||||
// Für VoiceOver eine **Aktion**, kein zweites Element: die Kachel
|
||||
// bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig
|
||||
// Kacheln mit je zwei Elementen wären sechzig Wischer.
|
||||
.accessibilityAction(named: Text("tile.play")) { trigger() }
|
||||
// Für VoiceOver der zweite Weg: am Telefon führt das Antippen in den
|
||||
// Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu
|
||||
// erreichen, das eine Geste verlangt.
|
||||
.accessibilityAction(named: Text("hero.details")) {
|
||||
if tapOpensDetails { return }
|
||||
push(route)
|
||||
}
|
||||
// **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht
|
||||
// das Menü verschwinden; ein Kreisel auf der Kachel käme für eine
|
||||
// Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht
|
||||
// sie dagegen schief, muss man es erfahren — sonst bliebe der Haken
|
||||
// einfach aus, und das sieht aus wie ein Fehler der App.
|
||||
.confirmationDialog(
|
||||
Text("downloads.quality.title"),
|
||||
isPresented: Binding(
|
||||
get: { downloadChoice != nil },
|
||||
set: { if !$0 { downloadChoice = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let ziel = downloadChoice {
|
||||
let wahl = DownloadQualityChoice(item: ziel, backend: session.backend)
|
||||
ForEach(wahl.available) { quality in
|
||||
Button(wahl.label(for: quality)) { chose(ziel, quality: quality) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let offen = pendingQuality {
|
||||
let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? []
|
||||
let empfohlen = offen.item.preferredVersion?.files.first.flatMap {
|
||||
service.preferredAudioStream(in: $0, of: offen.item)
|
||||
}
|
||||
ForEach(spuren) { stream in
|
||||
Button(
|
||||
stream == empfohlen
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
) {
|
||||
enqueue(offen.item, quality: offen.quality, audioStream: stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("error.title"),
|
||||
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
||||
) {
|
||||
Button("action.close") { errorMessage = nil }
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||
///
|
||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
||||
///
|
||||
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
|
||||
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
|
||||
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
|
||||
///
|
||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||
/// Inneren: die Kachel soll am Mac ein Link **sein**, nicht einer
|
||||
/// aussehen.
|
||||
@ViewBuilder
|
||||
private var kachel: some View {
|
||||
if tapOpensDetails {
|
||||
NavigationLink(value: route) { card() }
|
||||
} else {
|
||||
Button { trigger() } label: { card() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Oben rechts, weil der Fortschrittsbalken unten liegt.
|
||||
private var playButton: some View {
|
||||
Button(action: trigger) {
|
||||
Group {
|
||||
if quickPlay.resolvingID == item.id {
|
||||
ProgressView().controlSize(.small).tint(.white)
|
||||
} else {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
// Feste Grösse, damit der Kreisel den Knopf nicht wachsen lässt
|
||||
// und nichts springt, wenn nachgeschlagen wird.
|
||||
.frame(width: 28, height: 28)
|
||||
.background(accent.opacity(0.92), in: .circle)
|
||||
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(HaloSpacing.snug)
|
||||
.opacity(isPointerInside ? 1 : 0)
|
||||
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
||||
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||
///
|
||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||
/// *den* Titel, sondern seine Folgen, und dafür gibt es auf der Seite
|
||||
/// „Alle Folgen laden".
|
||||
private var isDownloadable: Bool {
|
||||
guard item.kind == .movie || item.kind == .episode else { return false }
|
||||
return !downloads.items.contains { $0.ratingKey == item.id }
|
||||
}
|
||||
|
||||
/// Holt die Dateiangaben nach und öffnet dann die Auswahl.
|
||||
///
|
||||
/// Erst beim Tippen, nicht beim Zeichnen: eine Reihe aus zwanzig Kacheln
|
||||
/// wären sonst zwanzig Anfragen für ein Menü, das selten aufgeht.
|
||||
private func prepareDownload() async {
|
||||
downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item
|
||||
}
|
||||
|
||||
private var service: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
|
||||
private func chose(_ ziel: MediaItem, quality: DownloadQuality) {
|
||||
downloadChoice = nil
|
||||
if service.needsAudioChoice(for: ziel, quality: quality) {
|
||||
pendingQuality = (ziel, quality)
|
||||
} else {
|
||||
enqueue(ziel, quality: quality, audioStream: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) {
|
||||
pendingQuality = nil
|
||||
do {
|
||||
_ = try service.download(ziel, quality: quality, audioStream: audioStream)
|
||||
} catch {
|
||||
errorMessage = error.explanation
|
||||
}
|
||||
}
|
||||
|
||||
private func trigger() {
|
||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||
}
|
||||
|
||||
/// Führt eine Serveraktion aus und lässt die Seite danach neu laden.
|
||||
///
|
||||
/// Das Neuladen gehört dazu: eine Folge, die als gesehen abgehakt wurde,
|
||||
/// gehört nicht mehr in „Weiterschauen", und der Fortschrittsbalken unter
|
||||
/// der Kachel stimmt sonst nicht mehr. Ohne diesen Schritt sähe es aus,
|
||||
/// als wäre nichts passiert.
|
||||
private func run(_ action: @escaping (any MediaBackend) async throws -> Void) {
|
||||
guard let backend = session.backend else { return }
|
||||
Task {
|
||||
do {
|
||||
try await action(backend)
|
||||
await itemChanged()
|
||||
} catch {
|
||||
errorMessage = ErrorText.describe(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Das Aktionsmenü auf einer Detailseite.
|
||||
///
|
||||
/// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als
|
||||
/// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig
|
||||
/// braucht: Abspielen und Herunterladen.
|
||||
///
|
||||
/// Welche Einträge erscheinen, entscheidet der Server: Plex kennt Sterne, aber
|
||||
/// keine Favoriten, Jellyfin Favoriten, aber keine Sterne. Ein Eintrag, der
|
||||
/// nichts bewirkt, ist schlimmer als keiner.
|
||||
struct ItemActionsMenu: View {
|
||||
let item: MediaItem
|
||||
/// Wird gerufen, wenn sich am Server etwas geändert hat und die
|
||||
/// Detailseite neu geladen werden sollte.
|
||||
let onChange: () async -> Void
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var capabilities: Set<BackendCapability> {
|
||||
session.backend?.capabilities ?? []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
// Dieselben Einträge wie im Kontextmenü einer Kachel — siehe
|
||||
// `ItemActionEntries`. Hier mit Kreisel und Fehlermeldung, weil
|
||||
// diese Ansicht lange genug lebt, um beides zu zeigen.
|
||||
ItemActionEntries(item: item, capabilities: capabilities, run: run)
|
||||
} label: {
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
.disabled(isWorking)
|
||||
.alert(
|
||||
Text("error.title"),
|
||||
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
||||
) {
|
||||
Button("action.close") { errorMessage = nil }
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Führt eine Serveraktion aus und lädt danach neu.
|
||||
///
|
||||
/// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und
|
||||
/// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die
|
||||
/// Aktionen dauern Millisekunden.
|
||||
private func run(_ action: @escaping (any MediaBackend) async throws -> Void) {
|
||||
guard let backend = session.backend else { return }
|
||||
isWorking = true
|
||||
|
||||
Task {
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
try await action(backend)
|
||||
await onChange()
|
||||
} catch {
|
||||
errorMessage = ErrorText.describe(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite eines Titels.
|
||||
///
|
||||
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
|
||||
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
|
||||
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
|
||||
struct MediaDetailView: View {
|
||||
let route: MediaRoute
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var state: LoadState<MediaItem> = .loading
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
case .loaded(let item):
|
||||
switch item.kind {
|
||||
case .show, .season:
|
||||
SeriesDetailView(item: item)
|
||||
default:
|
||||
PlayableDetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(state.value?.title ?? route.title)
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
if let item = state.value {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
ItemActionsMenu(item: item) { await load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: route.ratingKey) { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let server = session.backend else { return }
|
||||
state = await LoadState.load {
|
||||
try await server.item(id: route.ratingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
||||
///
|
||||
/// Bisher gab es ihn nicht. Wer über „Weiterschauen" oder „Zuletzt
|
||||
/// hinzugefügt" bei einer einzelnen Folge landete, saß dort fest — die
|
||||
/// übrigen Folgen derselben Staffel waren nur über den Umweg Bibliothek →
|
||||
/// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die
|
||||
/// Zeile existiert.
|
||||
struct ParentLinks: View {
|
||||
let item: MediaItem
|
||||
|
||||
var body: some View {
|
||||
if !destinations.isEmpty {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
ForEach(destinations, id: \.route.ratingKey) { destination in
|
||||
NavigationLink(value: destination.route) {
|
||||
Label {
|
||||
Text(destination.route.title)
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: destination.symbol)
|
||||
}
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background(Color.haloElevated, in: .capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Destination {
|
||||
let route: MediaRoute
|
||||
let symbol: String
|
||||
}
|
||||
|
||||
/// Die Stationen über diesem Eintrag.
|
||||
///
|
||||
/// Bei einer Folge zwei — Staffel und Serie; bei einer Staffel eine.
|
||||
/// Plex führt beide Kennungen am Eintrag mit, deshalb braucht es dafür
|
||||
/// keine zusätzliche Abfrage.
|
||||
private var destinations: [Destination] {
|
||||
switch item.kind {
|
||||
case .episode:
|
||||
var stops: [Destination] = []
|
||||
if let season = item.parentID {
|
||||
let title = item.parentIndex.map { String(localized: "season.short \($0)") }
|
||||
?? item.parentTitle
|
||||
?? String(localized: "detail.toSeason")
|
||||
stops.append(Destination(
|
||||
route: MediaRoute(ratingKey: season, title: title),
|
||||
symbol: "rectangle.stack"
|
||||
))
|
||||
}
|
||||
if let show = item.seriesID {
|
||||
stops.append(Destination(
|
||||
route: MediaRoute(
|
||||
ratingKey: show,
|
||||
title: item.seriesTitle ?? String(localized: "detail.toSeries")
|
||||
),
|
||||
symbol: "tv"
|
||||
))
|
||||
}
|
||||
return stops
|
||||
|
||||
case .season:
|
||||
guard let show = item.parentID else { return [] }
|
||||
return [Destination(
|
||||
route: MediaRoute(
|
||||
ratingKey: show,
|
||||
title: item.parentTitle ?? String(localized: "detail.toSeries")
|
||||
),
|
||||
symbol: "tv"
|
||||
)]
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||
/// einzelne Folge.
|
||||
struct PlayableDetailView: View {
|
||||
let item: MediaItem
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var selectedVersion = 0
|
||||
@State private var showsTechnicalDetails = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
header
|
||||
|
||||
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
|
||||
// unter dem Titel. Zwei Wege zum selben Ziel, einer davon als
|
||||
// Knopfreihe am unteren Rand, waren einer zu viel.
|
||||
if item.kind != .episode {
|
||||
ParentLinks(item: item)
|
||||
}
|
||||
|
||||
if item.hasMultipleVersions {
|
||||
VersionPicker(media: item.versions, selection: $selectedVersion)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
|
||||
if let summary = item.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
|
||||
if !item.genres.isEmpty {
|
||||
TagStrip(tags: item.genres)
|
||||
}
|
||||
|
||||
if !item.cast.isEmpty {
|
||||
CastRow(cast: item.cast)
|
||||
}
|
||||
|
||||
if let media = currentMedia, let part = media.files.first {
|
||||
TechnicalDetails(
|
||||
media: media,
|
||||
part: part,
|
||||
isExpanded: $showsTechnicalDetails
|
||||
)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var currentMedia: MediaVersion? {
|
||||
guard item.versions.indices.contains(selectedVersion) else { return item.versions.first }
|
||||
return item.versions[selectedVersion]
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: item.kind == .episode ? nil : metadataLine,
|
||||
metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil,
|
||||
summary: nil,
|
||||
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
|
||||
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
|
||||
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
|
||||
// Aufmacher schon einmal behoben worden, nur hier nicht.
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(
|
||||
for: item,
|
||||
backend: $0,
|
||||
placement: .detailHeader,
|
||||
compact: sizeClass == .compact
|
||||
)
|
||||
} ?? [],
|
||||
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath),
|
||||
// **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier
|
||||
// stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork`
|
||||
// auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant.
|
||||
// Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte
|
||||
// angeschnitten und der Kopf der Seite viel zu flach.
|
||||
//
|
||||
// Beides kommt deshalb aus derselben Quelle und mit demselben
|
||||
// Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das
|
||||
// Karussell auf dem Telefon quer ist.
|
||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||
) {
|
||||
Button {
|
||||
playback.play(item, mediaIndex: selectedVersion)
|
||||
} label: {
|
||||
Label(
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
||||
systemImage: "play.fill"
|
||||
)
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(currentMedia?.files.first == nil)
|
||||
|
||||
if let progress = item.watchProgress, progress > 0 {
|
||||
Button {
|
||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||
} label: {
|
||||
Label("detail.playFromStart", systemImage: "gobackward")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.haloCardTitle)
|
||||
.padding(HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
|
||||
///
|
||||
/// Serienname und „Staffel 2" führen dorthin, wohin sie zeigen. Vorher
|
||||
/// stand hier „S2 E1" als Text und darunter eine Reihe Knöpfe; wer zur
|
||||
/// Serie wollte, musste erst den Knopf finden. Der Name der Serie steht
|
||||
/// ohnehin da — dann soll er auch der Weg sein.
|
||||
@ViewBuilder
|
||||
private var episodeMetadata: some View {
|
||||
let plain = plainPieces
|
||||
|
||||
HStack(spacing: 0) {
|
||||
if let series = item.seriesTitle, let showID = item.seriesID {
|
||||
NavigationLink(value: MediaRoute(ratingKey: showID, title: series)) {
|
||||
Text(series).underline()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
separator
|
||||
} else if let series = item.seriesTitle {
|
||||
Text(series)
|
||||
separator
|
||||
}
|
||||
|
||||
if let season = item.parentIndex {
|
||||
let title = String(localized: "season.number \(season)")
|
||||
if let seasonID = item.parentID {
|
||||
NavigationLink(value: MediaRoute(ratingKey: seasonID, title: title)) {
|
||||
Text(title).underline()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
separator
|
||||
}
|
||||
|
||||
if let episode = item.index {
|
||||
// Über `String(localized:)` wie die Staffel darüber, nicht als
|
||||
// `LocalizedStringKey`. Bei der Schlüsselform ist der
|
||||
// Platzhalter nicht eindeutig: Xcode trug beim Auslesen
|
||||
// `%@` ein, während die Laufzeit `%lld` nachschlägt — und
|
||||
// zwischen zwei verschiedenen Schlüsseln findet man nie einen.
|
||||
Text(String(localized: "episode.number \(episode)"))
|
||||
if !plain.isEmpty { separator }
|
||||
}
|
||||
|
||||
Text(plain.joined(separator: " · "))
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Text(" · ")
|
||||
}
|
||||
|
||||
/// Was in der Zeile nur dasteht und nirgendwohin führt.
|
||||
private var plainPieces: [String] {
|
||||
var pieces: [String] = []
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||
if let rating = item.contentRating { pieces.append(rating) }
|
||||
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
|
||||
return pieces
|
||||
}
|
||||
|
||||
private var metadataLine: String? {
|
||||
var pieces: [String] = []
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||
if let rating = item.contentRating { pieces.append(rating) }
|
||||
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Auswahl zwischen mehreren Fassungen desselben Titels.
|
||||
struct VersionPicker: View {
|
||||
let media: [MediaVersion]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Text("detail.versions")
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Picker("detail.versions", selection: $selection) {
|
||||
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
||||
Text(version.qualityLabel).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Genres als Streifen.
|
||||
struct TagStrip: View {
|
||||
let tags: [String]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
ForEach(tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.tight)
|
||||
.background(Color.haloElevated, in: .capsule)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Besetzung mit Porträts.
|
||||
struct CastRow: View {
|
||||
let cast: [MediaPerson]
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text("detail.cast")
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
||||
ForEach(cast.prefix(20)) { role in
|
||||
VStack(spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "person"
|
||||
)
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(.circle)
|
||||
|
||||
Text(role.name)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let character = role.role {
|
||||
Text(character)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 90)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Die Detailseite einer Serie oder Staffel.
|
||||
///
|
||||
/// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste
|
||||
/// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen.
|
||||
struct SeriesDetailView: View {
|
||||
let item: MediaItem
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var children: LoadState<[MediaItem]> = .loading
|
||||
|
||||
// MARK: - Mehrere Folgen auf einmal
|
||||
|
||||
/// Ob gerade ausgewählt wird.
|
||||
///
|
||||
/// **Ein eigener Zustand statt „leere Auswahl heisst aus".** Sonst ließe
|
||||
/// sich der Modus nicht betreten, ohne sofort etwas anzukreuzen — und das
|
||||
/// Abwählen der letzten Folge würde ihn wieder verlassen.
|
||||
@State private var isSelecting = false
|
||||
@State private var selection: Set<String> = []
|
||||
@State private var sample: MediaItem?
|
||||
@State private var showsQualityChoice = false
|
||||
/// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel.
|
||||
@State private var pendingQuality: DownloadQuality?
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
header
|
||||
|
||||
ParentLinks(item: item)
|
||||
|
||||
if let summary = item.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
|
||||
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
|
||||
|
||||
// **Nur bei der Serie, nicht bei der Staffel.** Die Ebene
|
||||
// heisst Serie; sie zweimal anzubieten hiesse, zwei Orte für
|
||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||
// wird.
|
||||
if item.kind == .show {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries"
|
||||
)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
|
||||
childList
|
||||
|
||||
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
||||
}
|
||||
.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.haloRevealsTopChrome()
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
||||
.confirmationDialog(
|
||||
Text("downloads.quality.title"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
let choice = DownloadQualityChoice(
|
||||
item: sample ?? item,
|
||||
backend: session.backend
|
||||
)
|
||||
ForEach(choice.available) { quality in
|
||||
Button(choice.label(for: quality)) { chose(quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
// **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich
|
||||
// aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern
|
||||
// eine Prüfung der Geduld.
|
||||
if let muster = sample?.preferredVersion?.files.first {
|
||||
let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) }
|
||||
ForEach(muster.audioStreams) { stream in
|
||||
Button(
|
||||
stream == empfohlen
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
) {
|
||||
if let pendingQuality {
|
||||
Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("downloads.batch.title"),
|
||||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||||
) {
|
||||
Button("action.close") { summary = nil }
|
||||
} message: {
|
||||
if let summary { Text(summary) }
|
||||
}
|
||||
.task(id: item.id) { await loadChildren() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: metadataLine,
|
||||
summary: nil,
|
||||
// Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 —
|
||||
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
|
||||
// davon ein Streifen aus der Mitte. Derselbe Fehler war im
|
||||
// Aufmacher schon einmal behoben worden, nur hier nicht.
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(
|
||||
for: item,
|
||||
backend: $0,
|
||||
placement: .detailHeader,
|
||||
compact: sizeClass == .compact
|
||||
)
|
||||
} ?? [],
|
||||
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath),
|
||||
// **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier
|
||||
// stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork`
|
||||
// auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant.
|
||||
// Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte
|
||||
// angeschnitten und der Kopf der Seite viel zu flach.
|
||||
//
|
||||
// Beides kommt deshalb aus derselben Quelle und mit demselben
|
||||
// Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das
|
||||
// Karussell auf dem Telefon quer ist.
|
||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||
) {
|
||||
DownloadAllButton(container: item)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var childList: some View {
|
||||
switch children {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section)
|
||||
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
|
||||
case .loaded(let entries):
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||
// als von allem anderen auf dieser Seite.
|
||||
HStack {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
Spacer()
|
||||
if entries.contains(where: { $0.kind == .episode }) {
|
||||
selectionControls(for: entries)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
row(for: entry)
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||
@ViewBuilder
|
||||
private func row(for entry: MediaItem) -> some View {
|
||||
if isSelecting, entry.kind == .episode {
|
||||
let gewaehlt = selection.contains(entry.id)
|
||||
// Hier darf der Kreis **im** Label stehen: die ganze Zeile ist der
|
||||
// Knopf, es gibt nichts zu verschachteln.
|
||||
Button {
|
||||
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
ChildRow(item: entry)
|
||||
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
||||
} else {
|
||||
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
||||
// Label eines `NavigationLink` löst auf iOS nie aus — der Link
|
||||
// macht sein Label zu einer einzigen Fläche, und `.plain` lässt das
|
||||
// Versagen lautlos geschehen. Dieselbe Falle steht in
|
||||
// `QuickPlayTile` beschrieben.
|
||||
//
|
||||
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
||||
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
||||
// dem Knopf verschwinden.
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
||||
ChildRow(item: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// **Nur bei Folgen.** Für eine ganze Staffel gibt es den Knopf
|
||||
// oben im Kopf der Seite; zweimal dasselbe Angebot auf einem
|
||||
// Bildschirm ist keine Abkürzung, sondern eine Frage.
|
||||
if entry.kind == .episode {
|
||||
DownloadButton(item: entry, loadsDetails: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func selectionControls(for entries: [MediaItem]) -> some View {
|
||||
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
||||
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
if isSelecting {
|
||||
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
||||
selection = selection.count == folgen.count ? [] : Set(folgen)
|
||||
}
|
||||
Button("action.cancel") {
|
||||
isSelecting = false
|
||||
selection = []
|
||||
}
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else {
|
||||
Button("detail.select") { isSelecting = true }
|
||||
}
|
||||
}
|
||||
.font(.haloCaption)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
/// Die Leiste unten, solange etwas angekreuzt ist.
|
||||
///
|
||||
/// Als Sicherheitsrand und nicht als Überzug: so endet die Liste darüber,
|
||||
/// statt dass die letzte Folge dahinter verschwindet.
|
||||
@ViewBuilder
|
||||
private var selectionBar: some View {
|
||||
if isSelecting, !selection.isEmpty {
|
||||
Button {
|
||||
Task { await askForQuality() }
|
||||
} label: {
|
||||
Group {
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label(
|
||||
String(localized: "detail.loadSelected \(selection.count)"),
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(isWorking)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedEpisodes: [MediaItem] {
|
||||
guard case .loaded(let entries) = children else { return [] }
|
||||
return entries.filter { selection.contains($0.id) }
|
||||
}
|
||||
|
||||
/// Holt die Dateiangaben **einer** Folge und fragt dann nach der Qualität.
|
||||
///
|
||||
/// Eine reicht: die Folgen einer Staffel sind in derselben Fassung
|
||||
/// kodiert, und zwanzig Anfragen für eine Frage, die einmal gestellt wird,
|
||||
/// wären zwanzig zu viel. Geladen wird danach jede Folge einzeln — dort
|
||||
/// zählt wieder ihre eigene Größe.
|
||||
private func askForQuality() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
if let erste = selectedEpisodes.first {
|
||||
sample = (try? await session.backend?.item(id: erste.id)) ?? erste
|
||||
}
|
||||
showsQualityChoice = true
|
||||
}
|
||||
|
||||
private var downloadService: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
|
||||
private func chose(_ quality: DownloadQuality) {
|
||||
if let muster = sample, downloadService.needsAudioChoice(for: muster, quality: quality) {
|
||||
pendingQuality = quality
|
||||
} else {
|
||||
Task { await loadSelected(quality: quality, audioStreamIndex: nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSelected(quality: DownloadQuality, audioStreamIndex: Int?) async {
|
||||
pendingQuality = nil
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
let result = await downloadService.download(
|
||||
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
|
||||
)
|
||||
|
||||
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||
if let erster = result.refused.first {
|
||||
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
|
||||
// er für alle übrigen derselbe.
|
||||
zeilen.append(erster.1.explanation)
|
||||
}
|
||||
if result.unavailable > 0 {
|
||||
zeilen.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
|
||||
}
|
||||
summary = zeilen.joined(separator: "\n")
|
||||
|
||||
isSelecting = false
|
||||
selection = []
|
||||
}
|
||||
|
||||
private var metadataLine: String? {
|
||||
var pieces: [String] = []
|
||||
// Bei einer Staffel steht oben der Serienname — welche Staffel es
|
||||
// ist, muss deshalb hierher.
|
||||
if item.kind == .season, let number = item.index {
|
||||
pieces.append(String(localized: "season.short \(number)"))
|
||||
}
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let episodeCount = item.episodeCount {
|
||||
pieces.append(String(localized: "detail.episodeCount \(episodeCount)"))
|
||||
}
|
||||
if let viewed = item.playedEpisodeCount, let total = item.episodeCount, viewed < total {
|
||||
pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)"))
|
||||
}
|
||||
if let rating = item.contentRating { pieces.append(rating) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func loadChildren() async {
|
||||
guard let server = session.backend else { return }
|
||||
children = await LoadState.load {
|
||||
try await server.children(of: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Staffel- oder Folgenzeile.
|
||||
private struct ChildRow: View {
|
||||
let item: MediaItem
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(
|
||||
path: item.thumb,
|
||||
width: item.kind == .season ? 120 : 240,
|
||||
height: item.kind == .season ? 180 : 135
|
||||
),
|
||||
aspectRatio: item.kind == .season ? PosterCard.aspectRatio : LandscapeCard.aspectRatio,
|
||||
fallbackSymbol: "play.rectangle"
|
||||
)
|
||||
.frame(width: item.kind == .season ? 48 : 96)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress = item.watchProgress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(rowTitle)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.accessibilityLabel(Text("item.watched"))
|
||||
}
|
||||
}
|
||||
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
||||
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
||||
// Bildschirmrand.
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var rowTitle: String {
|
||||
if item.kind == .episode, let episode = item.index {
|
||||
return "\(episode). \(item.title)"
|
||||
}
|
||||
return item.title
|
||||
}
|
||||
|
||||
private var subtitle: String? {
|
||||
switch item.kind {
|
||||
case .season:
|
||||
item.episodeCount.map { String(localized: "detail.episodeCount \($0)") }
|
||||
case .episode:
|
||||
item.duration.map { Duration.milliseconds($0).runtimeLabel }
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
||||
///
|
||||
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Halo sie
|
||||
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
|
||||
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
|
||||
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
|
||||
struct TechnicalDetails: View {
|
||||
let media: MediaVersion
|
||||
let part: MediaFile
|
||||
@Binding var isExpanded: Bool
|
||||
|
||||
/// Was mit dieser Datei passieren würde.
|
||||
///
|
||||
/// **Mit Serverhilfe gerechnet, nicht ohne.** Hier stand `.directPlayOnly`
|
||||
/// — damals eine brauchbare Auskunft, weil ohne Serverhilfe die zweite
|
||||
/// Engine übernahm und auch so hiess. Seit es sie nicht mehr gibt, kam
|
||||
/// dort „Direkt (Systemplayer)" heraus, direkt über der Zeile „iOS öffnet
|
||||
/// den Container MKV nicht" — zwei Angaben, die sich widersprechen, und
|
||||
/// die zweite stimmte.
|
||||
///
|
||||
/// Ohne Bandbreitengrenze, weil sie von der Verbindung abhängt und diese
|
||||
/// Seite sie nicht kennt. Gezeigt wird damit der Fall im WLAN.
|
||||
private var decision: PlaybackDecision {
|
||||
PlaybackDecider().decide(
|
||||
profile: media.profile(file: part),
|
||||
policy: PlaybackPolicy(allowsTranscoding: true)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup(isExpanded: $isExpanded) {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
row("technical.playbackMethod", playbackMethodLabel)
|
||||
if let reason = rationaleLabel {
|
||||
row("technical.reason", reason)
|
||||
}
|
||||
|
||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||
|
||||
if let container = part.container { row("technical.container", container.uppercased()) }
|
||||
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
||||
if let width = media.width, let height = media.height {
|
||||
row("technical.resolution", "\(width) × \(height)")
|
||||
}
|
||||
if let bitrate = media.bitrate {
|
||||
row("technical.bitrate", "\(bitrate / 1000) Mbit/s")
|
||||
}
|
||||
if let size = part.size {
|
||||
row("technical.size", size.formatted(.byteCount(style: .file)))
|
||||
}
|
||||
|
||||
if !part.audioStreams.isEmpty {
|
||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||
ForEach(part.audioStreams) { stream in
|
||||
row("technical.audio", trackLabel(stream))
|
||||
}
|
||||
}
|
||||
|
||||
if !part.subtitleStreams.isEmpty {
|
||||
ForEach(part.subtitleStreams) { stream in
|
||||
row("technical.subtitle", trackLabel(stream))
|
||||
}
|
||||
}
|
||||
|
||||
if let file = part.file {
|
||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("technical.file")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Text(file)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
} label: {
|
||||
Text("technical.title")
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
.tint(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func trackLabel(_ stream: MediaStream) -> String {
|
||||
stream.displayTitle ?? [stream.language, stream.codec?.uppercased()]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var playbackMethodLabel: String {
|
||||
switch decision.method {
|
||||
case .directPlay:
|
||||
String(localized: "technical.directPlayNative")
|
||||
// **Zwei Fälle unter einem Namen wären eine Lüge.** Umgepackt bleibt
|
||||
// das Bild unberührt — der Ton nur dann, wenn AVPlayer ihn kennt.
|
||||
case .remux:
|
||||
decision.rationale.contains { if case .audioCodecUnsupported = $0 { true } else { false } }
|
||||
? String(localized: "technical.remuxAudio")
|
||||
: String(localized: "technical.remux")
|
||||
case .transcode:
|
||||
String(localized: "technical.transcode")
|
||||
}
|
||||
}
|
||||
|
||||
/// Der erste Grund, der gegen den Systemplayer sprach — mehrere
|
||||
/// aufzuzählen hilft niemandem, es reicht einer.
|
||||
private var rationaleLabel: String? {
|
||||
for reason in decision.rationale {
|
||||
switch reason {
|
||||
case .containerUnsupported(let container):
|
||||
return String(localized: "technical.reason.container \(container.uppercased())")
|
||||
case .videoCodecUnsupported(let codec):
|
||||
return String(localized: "technical.reason.videoCodec \(codec.uppercased())")
|
||||
case .audioCodecUnsupported(let codec):
|
||||
return String(localized: "technical.reason.audioCodec \(codec.uppercased())")
|
||||
case .streamsUnknown:
|
||||
return String(localized: "technical.reason.unanalyzed")
|
||||
case .qualityChosen:
|
||||
return String(localized: "technical.reason.chosen")
|
||||
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Der Download-Knopf auf einer Detailseite.
|
||||
///
|
||||
/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline
|
||||
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
|
||||
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
|
||||
struct DownloadButton: View {
|
||||
let item: MediaItem
|
||||
var mediaIndex: Int = 0
|
||||
/// Ob die Dateiangaben erst noch geholt werden müssen.
|
||||
///
|
||||
/// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste
|
||||
/// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem
|
||||
/// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was
|
||||
/// die Prüfung auf freien Speicherplatz stillschweigend überspringt.
|
||||
/// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund
|
||||
/// einzeln nach.
|
||||
///
|
||||
/// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen
|
||||
/// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens
|
||||
/// nicht drückt.
|
||||
var loadsDetails: Bool = false
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var refusal: DownloadRefusal?
|
||||
@State private var showsQualityChoice = false
|
||||
/// Der nachgeholte Titel — gilt ab dann statt `item`.
|
||||
@State private var detailed: MediaItem?
|
||||
@State private var isLoadingDetails = false
|
||||
/// Die Stufe steht fest, die Tonspur fehlt noch.
|
||||
///
|
||||
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
|
||||
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
|
||||
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
|
||||
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
|
||||
/// zurück.
|
||||
@State private var pendingQuality: DownloadQuality?
|
||||
|
||||
private var existing: DownloadItem? {
|
||||
downloads.items.first { $0.ratingKey == item.id }
|
||||
}
|
||||
|
||||
/// Der Titel, aus dem Fassungen und Größen gelesen werden.
|
||||
///
|
||||
/// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem
|
||||
/// einen.
|
||||
private var source: MediaItem { detailed ?? item }
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
act()
|
||||
} label: {
|
||||
Group {
|
||||
if isLoadingDetails {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label(label, systemImage: symbol)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.haloCardTitle)
|
||||
}
|
||||
}
|
||||
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
||||
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
||||
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
||||
.frame(width: 22, height: 22)
|
||||
.padding(HaloSpacing.snug)
|
||||
}
|
||||
.disabled(isLoadingDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text(label))
|
||||
.confirmationDialog(
|
||||
Text("downloads.quality.title"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(choice.available) { quality in
|
||||
Button(choice.label(for: quality)) { chose(quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("downloads.audio.title"),
|
||||
isPresented: Binding(
|
||||
get: { pendingQuality != nil },
|
||||
set: { if !$0 { pendingQuality = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(audioStreams) { stream in
|
||||
Button(audioLabel(stream)) {
|
||||
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.audio.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("downloads.refused.title"),
|
||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||
) {
|
||||
Button("action.close") { refusal = nil }
|
||||
} message: {
|
||||
if let refusal { Text(refusal.explanation) }
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch existing?.state {
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .downloading, .queued, .waiting: "stop.circle"
|
||||
case .paused: "play.circle"
|
||||
case .failed: continues ? "play.circle" : "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90"
|
||||
case nil: "arrow.down.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var label: LocalizedStringKey {
|
||||
switch existing?.state {
|
||||
case .completed: "downloads.available"
|
||||
case .downloading, .queued, .waiting: "downloads.cancel"
|
||||
case .paused: "downloads.resume"
|
||||
case .failed: continues ? "downloads.resume" : "action.retry"
|
||||
case nil: "downloads.download"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ein Tipp dort weitermacht, wo es aufgehört hat.
|
||||
///
|
||||
/// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur,
|
||||
/// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt
|
||||
/// es von vorn an, und dann heißt der Knopf auch so.
|
||||
private var continues: Bool { existing?.resumeData != nil }
|
||||
|
||||
private func act() {
|
||||
guard let existing else {
|
||||
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
||||
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
||||
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
||||
guard loadsDetails, detailed == nil else {
|
||||
showsQualityChoice = true
|
||||
return
|
||||
}
|
||||
Task { await loadDetailsThenAsk() }
|
||||
return
|
||||
}
|
||||
|
||||
switch existing.state {
|
||||
case .completed:
|
||||
// Ein fertiger Download wird hier nicht angetastet. Löschen geht
|
||||
// über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile
|
||||
// soll nicht 40 GB verwerfen.
|
||||
break
|
||||
case .downloading, .queued, .waiting:
|
||||
Task { await downloads.cancel(existing) }
|
||||
case .paused, .failed:
|
||||
Task { await downloads.resume(existing) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt die Dateiangaben nach und fragt dann nach der Qualität.
|
||||
///
|
||||
/// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur
|
||||
/// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus
|
||||
/// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp
|
||||
/// kommentarlos nichts tut.
|
||||
private func loadDetailsThenAsk() async {
|
||||
isLoadingDetails = true
|
||||
detailed = try? await session.backend?.item(id: item.id)
|
||||
isLoadingDetails = false
|
||||
showsQualityChoice = true
|
||||
}
|
||||
|
||||
private var service: DownloadService {
|
||||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||||
}
|
||||
|
||||
private var audioStreams: [MediaStream] {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
return media?.files.first?.audioStreams ?? []
|
||||
}
|
||||
|
||||
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
|
||||
private var recommended: MediaStream? {
|
||||
let media = source.versions.indices.contains(mediaIndex)
|
||||
? source.versions[mediaIndex] : source.preferredVersion
|
||||
guard let part = media?.files.first else { return nil }
|
||||
return service.preferredAudioStream(in: part, of: source)
|
||||
}
|
||||
|
||||
private func audioLabel(_ stream: MediaStream) -> String {
|
||||
stream == recommended
|
||||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||||
: stream.name
|
||||
}
|
||||
|
||||
/// Nach der Stufe: entweder fragen oder einreihen.
|
||||
private func chose(_ quality: DownloadQuality) {
|
||||
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
|
||||
pendingQuality = quality
|
||||
} else {
|
||||
enqueue(quality: quality, audioStream: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
|
||||
pendingQuality = nil
|
||||
do {
|
||||
_ = try service.download(
|
||||
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
|
||||
)
|
||||
} catch {
|
||||
refusal = error
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zur Wahl steht — siehe `DownloadQualityChoice`.
|
||||
private var choice: DownloadQualityChoice {
|
||||
DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadQuality {
|
||||
var localizedName: String.LocalizationValue {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .fullHD: "quality.fullHD"
|
||||
case .hd: "quality.hd"
|
||||
case .low: "quality.lowRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Knopf für eine ganze Staffel oder Serie.
|
||||
struct DownloadAllButton: View {
|
||||
let container: MediaItem
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
Task { await downloadAll() }
|
||||
} label: {
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label(
|
||||
container.kind == .show ? "downloads.allEpisodes" : "downloads.season",
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.haloTextSecondary)
|
||||
.disabled(isWorking)
|
||||
.alert(
|
||||
Text("downloads.batch.title"),
|
||||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||||
) {
|
||||
Button("action.close") { summary = nil }
|
||||
} message: {
|
||||
if let summary { Text(summary) }
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAll() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
let service = DownloadService(
|
||||
backend: session.backend, coordinator: downloads, languages: languages
|
||||
)
|
||||
let result = await service.downloadAll(in: container)
|
||||
|
||||
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||
if !result.refused.isEmpty {
|
||||
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
|
||||
// er für alle übrigen derselbe.
|
||||
lines.append(result.refused[0].1.explanation)
|
||||
}
|
||||
if result.unavailable > 0 {
|
||||
lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
|
||||
}
|
||||
summary = lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadRefusal {
|
||||
/// Der Grund im Klartext.
|
||||
var explanation: String {
|
||||
switch self {
|
||||
case .alreadyExists:
|
||||
String(localized: "downloads.refused.exists")
|
||||
case .storageLimitReached(let limit, let used):
|
||||
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
|
||||
case .noDownloadableFile(let reason):
|
||||
reason
|
||||
case .insufficientDeviceSpace(let required, let available):
|
||||
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
|
||||
///
|
||||
/// **Als eigener Typ, weil es die Frage inzwischen an zwei Stellen gibt:** am
|
||||
/// Knopf einer einzelnen Folge und an der Leiste, mit der man mehrere auf
|
||||
/// einmal lädt. Dieselbe Entscheidung zweimal zu schreiben hiesse, dass sie
|
||||
/// sich auseinanderentwickelt — und die erste Kopie wäre die mit dem Fehler.
|
||||
///
|
||||
/// Kein `View`: hier steht keine Zeile SwiftUI, nur die Frage, was angeboten
|
||||
/// werden darf und wie es beschriftet ist.
|
||||
struct DownloadQualityChoice {
|
||||
let item: MediaItem
|
||||
var mediaIndex: Int = 0
|
||||
let backend: (any MediaBackend)?
|
||||
|
||||
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
||||
///
|
||||
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
|
||||
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
|
||||
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
|
||||
var available: [DownloadQuality] {
|
||||
let alle = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
||||
guard allowsCompressed else { return [.original] }
|
||||
return alle
|
||||
}
|
||||
|
||||
/// Die Beschriftung samt Auflösung und geschätzter Größe.
|
||||
///
|
||||
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
|
||||
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
|
||||
/// herunterrechnet oder nicht.
|
||||
func label(for quality: DownloadQuality) -> String {
|
||||
let originalBytes = currentMedia?.files.first?.size ?? 0
|
||||
let bytes = quality.estimatedBytes(
|
||||
durationMilliseconds: item.duration,
|
||||
originalBytes: originalBytes
|
||||
)
|
||||
|
||||
var name = String(localized: quality.localizedName)
|
||||
if quality == .original, let resolution = currentMedia?.resolutionLabel {
|
||||
name += " · \(resolution)"
|
||||
}
|
||||
|
||||
guard bytes > 0 else { return name }
|
||||
|
||||
let size = bytes.formatted(.byteCount(style: .file))
|
||||
return quality == .original
|
||||
? "\(name) · \(size)"
|
||||
: String(localized: "downloads.quality.approx \(name) \(size)")
|
||||
}
|
||||
|
||||
private var currentMedia: MediaVersion? {
|
||||
item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion
|
||||
}
|
||||
|
||||
private var allowsCompressed: Bool {
|
||||
backend?.compressedDownloadURL(
|
||||
itemID: item.id,
|
||||
versionIndex: mediaIndex,
|
||||
maximumBitrate: 8_000,
|
||||
resolution: nil
|
||||
) != nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import HaloPlayback
|
||||
|
||||
/// Was offline ist und was gerade geladen wird.
|
||||
struct DownloadsView: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if downloads.items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("downloads.empty.title", systemImage: "arrow.down.circle")
|
||||
} description: {
|
||||
Text("downloads.empty.description")
|
||||
}
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.downloads"))
|
||||
.toolbar {
|
||||
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
||||
// mehr, und dies ist der Weg dorthin.
|
||||
if sizeClass == .compact {
|
||||
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
||||
}
|
||||
}
|
||||
.haloInlineTitle()
|
||||
// Keine Rückfrage beim Entfernen.
|
||||
//
|
||||
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
||||
// aber nur die heruntergeladene Kopie: der Titel bleibt auf dem Server
|
||||
// und ist mit einem Tippen wieder da. Wer nach links wischt und auf
|
||||
// „Löschen" tippt, hat zweimal gesagt, was er will — ein drittes Mal
|
||||
// zu fragen ist keine Vorsicht, sondern Aufhalten.
|
||||
}
|
||||
|
||||
/// Was die Übertragung gerade aufhält.
|
||||
private var hold: TransferHold {
|
||||
TransferHold.current(
|
||||
isWiFiOnly: downloads.isWiFiOnly,
|
||||
isReachable: network.isReachable,
|
||||
isExpensive: network.isExpensive
|
||||
)
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
StorageSection()
|
||||
|
||||
// Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet,
|
||||
// während etwas lädt, will meistens genau das sehen.
|
||||
if !active.isEmpty {
|
||||
Section("downloads.active") {
|
||||
// Die Hintergrund-Sitzung wartet in diesen Fällen von
|
||||
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
|
||||
// Hinweis sieht ein wartender Download genauso aus wie
|
||||
// ein hängender.
|
||||
if hold.isHolding { HoldNotice(hold: hold) }
|
||||
|
||||
ForEach(active) { item in
|
||||
DownloadRow(
|
||||
item: item,
|
||||
hold: hold,
|
||||
onRemove: { Task { await downloads.cancel(item) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(completedGroups, id: \.title) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.items) { item in
|
||||
// Fertige Downloads wartet nichts mehr auf.
|
||||
DownloadRow(item: item, hold: .none, onRemove: { Task { await downloads.cancel(item) } })
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { play(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var active: [DownloadItem] {
|
||||
downloads.items.filter { $0.state != .completed }
|
||||
}
|
||||
|
||||
/// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen.
|
||||
private var completedGroups: [(title: String, items: [DownloadItem])] {
|
||||
let completed = downloads.items.filter { $0.state == .completed }
|
||||
let grouped = Dictionary(grouping: completed) {
|
||||
$0.seriesTitle ?? String(localized: "downloads.movies")
|
||||
}
|
||||
return grouped
|
||||
.map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) }
|
||||
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool {
|
||||
(lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
|
||||
}
|
||||
|
||||
private func play(_ item: DownloadItem) {
|
||||
guard item.state == .completed else { return }
|
||||
playback.play(PlaybackRequest(
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
||||
private struct StorageSection: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("downloads.used")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
Spacer()
|
||||
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
|
||||
if let limit = downloads.storageLimitBytes, limit > 0 {
|
||||
ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1))
|
||||
.tint(accent)
|
||||
.accessibilityLabel(Text("downloads.used"))
|
||||
}
|
||||
|
||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Zeile in der Downloads-Ansicht.
|
||||
private struct DownloadRow: View {
|
||||
let item: DownloadItem
|
||||
let hold: TransferHold
|
||||
let onRemove: () -> Void
|
||||
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = item.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
action
|
||||
}
|
||||
|
||||
if item.state != .completed {
|
||||
progress
|
||||
}
|
||||
|
||||
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
|
||||
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
|
||||
if hold.isHolding, item.state == .downloading || item.state == .queued {
|
||||
Label(
|
||||
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
||||
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
||||
)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
if let message = item.failureMessage {
|
||||
Text(message)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.tight)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button("action.delete", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var action: some View {
|
||||
switch item.state {
|
||||
case .downloading:
|
||||
Button {
|
||||
Task { await downloads.pause(item) }
|
||||
} label: {
|
||||
Image(systemName: "pause.circle").font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(accent)
|
||||
.accessibilityLabel(Text("downloads.pause"))
|
||||
|
||||
case .paused, .failed:
|
||||
// **Fortsetzen und erneut versuchen sind nicht dasselbe**, und
|
||||
// der Knopf hat beides gleich genannt. „Fortsetzen" stand auch
|
||||
// dort, wo es nichts fortzusetzen gab — eine Zusage, die der Code
|
||||
// nicht halten konnte: ein gescheiterter Download fing wieder bei
|
||||
// null an. Jetzt entscheidet der Wiederaufnahme-Datensatz, und er
|
||||
// entsteht genau dann, wenn eine Übertragung unterbrochen wurde.
|
||||
Button {
|
||||
Task { await downloads.resume(item) }
|
||||
} label: {
|
||||
Image(systemName: continues ? "play.circle" : "arrow.clockwise.circle")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(accent)
|
||||
.accessibilityLabel(Text(continues ? "downloads.resume" : "action.retry"))
|
||||
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.accessibilityLabel(Text("downloads.available"))
|
||||
|
||||
case .queued, .waiting:
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.accessibilityLabel(Text("downloads.queued"))
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
||||
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
||||
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
||||
// Download.
|
||||
if let value = item.progress {
|
||||
ProgressView(value: value).tint(accent)
|
||||
} else {
|
||||
ProgressView().progressViewStyle(.linear).tint(accent)
|
||||
}
|
||||
|
||||
// **Links die Zeile, rechts die Prozente — und beides steht
|
||||
// still.**
|
||||
//
|
||||
// Vorher war es eine einzige linksbündige Zeile, und sie zappelte
|
||||
// bei jeder Aktualisierung: in einer Proportionalschrift ist die
|
||||
// 1 schmal und die 8 breit, also verschob „181,2 → 190,1" alles
|
||||
// dahinter. Zweimal je Sekunde.
|
||||
//
|
||||
// Drei Dinge halten es jetzt ruhig: Ziffern in fester Breite, ein
|
||||
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
|
||||
// Prozente am rechten Rand — die wachsen nach innen und schieben
|
||||
// nichts.
|
||||
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
|
||||
Text(statusLine)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: HaloSpacing.snug)
|
||||
if let percentage {
|
||||
Text(percentage)
|
||||
}
|
||||
}
|
||||
.font(.haloCaption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Fortschritt in Prozent — `nil`, solange die Gesamtgröße fehlt.
|
||||
///
|
||||
/// Die Zahl, nach der man eigentlich sieht. „181,2 MB von 890,2 MB" sagt
|
||||
/// dasselbe, aber man muss es ausrechnen.
|
||||
private var percentage: String? {
|
||||
guard item.state != .completed, let anteil = item.progress else { return nil }
|
||||
return anteil.formatted(.percent.precision(.fractionLength(0)))
|
||||
}
|
||||
|
||||
|
||||
/// Ob ein Antippen dort weitermacht, wo es aufgehört hat.
|
||||
///
|
||||
/// Ein angehaltener Download hat seinen Wiederaufnahme-Datensatz immer;
|
||||
/// ein gescheiterter nur, wenn ihn das Netz unterbrochen hat und nicht der
|
||||
/// Server abgelehnt.
|
||||
private var continues: Bool {
|
||||
item.state == .paused || item.resumeData != nil
|
||||
}
|
||||
|
||||
/// Eine Byte-Zahl mit **fester** Nachkommastelle.
|
||||
///
|
||||
/// **Der letzte Rest des Zappelns.** `.byteCount` lässt die Null hinter
|
||||
/// dem Komma weg: aus „186,0 MB" wird „186 MB", und die Zeile ist zwei
|
||||
/// Zeichen kürzer. Gemessen an sechs aufeinanderfolgenden Sekunden war das
|
||||
/// die einzige, die noch sprang.
|
||||
///
|
||||
/// `usage: .asProvided`, damit die Einheit bleibt, die hier gewählt wurde
|
||||
/// — sonst rechnet die Formatierung eigenmächtig um.
|
||||
private func size(_ value: Int64) -> String {
|
||||
let (menge, einheit): (Double, UnitInformationStorage) = switch value {
|
||||
case 1_000_000_000...: (Double(value) / 1_000_000_000, .gigabytes)
|
||||
case 1_000_000...: (Double(value) / 1_000_000, .megabytes)
|
||||
case 1_000...: (Double(value) / 1_000, .kilobytes)
|
||||
default: (Double(value), .bytes)
|
||||
}
|
||||
return Measurement(value: menge, unit: einheit).formatted(
|
||||
.measurement(
|
||||
width: .abbreviated,
|
||||
usage: .asProvided,
|
||||
numberFormatStyle: .number.precision(
|
||||
.fractionLength(einheit == .bytes ? 0 : 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Wie schnell es gerade geht — `nil`, solange nichts gemessen ist.
|
||||
private var speed: String? {
|
||||
guard let perSecond = downloads.rate(forRatingKey: item.ratingKey)?.bytesPerSecond,
|
||||
perSecond > 0
|
||||
else { return nil }
|
||||
return String(localized: "downloads.perSecond \(size(Int64(perSecond)))")
|
||||
}
|
||||
|
||||
/// Wie lange es bei diesem Tempo noch dauert.
|
||||
private var remaining: String? {
|
||||
guard let rate = downloads.rate(forRatingKey: item.ratingKey),
|
||||
let rest = rate.remaining(received: item.receivedBytes, total: item.totalBytes)
|
||||
else { return nil }
|
||||
// Unter einer Minute keine Zahl: „noch 0 Min" wäre falsch und „noch
|
||||
// 43 Sek" genauer, als die Schätzung ist.
|
||||
guard rest >= .seconds(60) else {
|
||||
return String(localized: "downloads.remaining.underMinute")
|
||||
}
|
||||
return String(localized: "downloads.remaining \(rest.runtimeLabel)")
|
||||
}
|
||||
|
||||
private var statusLine: String {
|
||||
switch item.state {
|
||||
case .downloading where item.totalBytes > 0:
|
||||
// **Jede Zahl in ihrem eigenen Mass.** Beide auf das des Ziels zu
|
||||
// zwingen war der naheliegende Weg gegen den Einheitenwechsel —
|
||||
// und er machte den Anfang unlesbar: bei einem Ziel von 2 GB
|
||||
// stand dort „0,03 GB / 2 GB". Gegen das Zappeln hilft die feste
|
||||
// Ziffernbreite; der Wechsel von MB auf GB kommt einmal je
|
||||
// Download und ist der kleinere Preis.
|
||||
["\(size(item.receivedBytes)) / \(size(item.totalBytes))", speed, remaining]
|
||||
.compactMap(\.self).joined(separator: " · ")
|
||||
case .downloading:
|
||||
// Ohne bekannte Gesamtgröße gibt es keine Restzeit — das Tempo
|
||||
// aber schon, und es ist genau dort die einzige Auskunft darüber,
|
||||
// ob überhaupt etwas durchgeht.
|
||||
[size(item.receivedBytes), speed].compactMap(\.self).joined(separator: " · ")
|
||||
case .paused:
|
||||
String(localized: "downloads.paused")
|
||||
case .queued, .waiting:
|
||||
String(localized: "downloads.queued")
|
||||
case .failed:
|
||||
String(localized: "downloads.failed")
|
||||
case .completed:
|
||||
item.totalBytes.formatted(.byteCount(style: .file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
|
||||
private struct HoldNotice: View {
|
||||
let hold: TransferHold
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
||||
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
||||
.font(.title3)
|
||||
.foregroundStyle(accent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text(hold == .offline
|
||||
? "downloads.hold.offline.description"
|
||||
: "downloads.hold.wifi.description")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.tight)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
||||
///
|
||||
/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wechselt der Aufmacher alle
|
||||
/// paar Sekunden; die Punkte darunter zeigen, wie viele es sind und wo man
|
||||
/// gerade ist.
|
||||
///
|
||||
/// **Übergeblendet, nicht durchgeblättert.** Vorher lag hier ein einrastender
|
||||
/// Streifen, der die Titel wie Buchseiten seitwärts durchschob. Das hatte eine
|
||||
/// Nebenwirkung, die nichts mit dem Blättern zu tun schien: der Aufmacher füllt
|
||||
/// die Fläche unter der Seitenleiste, indem er seinen eigenen Rand gespiegelt
|
||||
/// weiterzeichnet. Steht das Bild still, fällt das niemandem auf. Schiebt es
|
||||
/// sich dagegen seitwärts, wandern das echte und das gespiegelte Stück
|
||||
/// gegeneinander, und die Naht dazwischen wird zum auffälligsten Ding auf dem
|
||||
/// Bildschirm.
|
||||
///
|
||||
/// Jetzt rutscht das neue Bild nur ein kleines Stück herein und legt sich
|
||||
/// dabei über das alte. Nichts wandert weit genug, als dass ein Versatz
|
||||
/// auffiele.
|
||||
///
|
||||
/// **Es wird nicht gekreuzt, sondern überdeckt.** Beim Kreuzblenden sind
|
||||
/// beide Bilder gleichzeitig halbdurchsichtig — und dazwischen scheint der
|
||||
/// Untergrund durch, was als helles Aufblitzen des vorigen Titels zu sehen
|
||||
/// war. Hier bleibt das alte Bild voll deckend liegen, bis das neue darüber
|
||||
/// undurchsichtig geworden ist; erst dann verschwindet es. Zu jedem Zeitpunkt
|
||||
/// ist die Fläche gedeckt.
|
||||
struct HeroCarousel: View {
|
||||
let items: [MediaItem]
|
||||
|
||||
/// Wie lange ein Titel stehen bleibt.
|
||||
///
|
||||
/// Lang genug, um Titel, Beschreibung und Knöpfe zu lesen — bei drei
|
||||
/// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat.
|
||||
private static let dwell: Duration = .seconds(7)
|
||||
|
||||
/// Wie weit das Bild dabei rutscht.
|
||||
///
|
||||
/// Klein gehalten: es soll eine Richtung andeuten, nicht eine Strecke
|
||||
/// zurücklegen. Alles darüber sieht wieder nach Blättern aus.
|
||||
private static let slide: CGFloat = 32
|
||||
|
||||
@State private var shownID: String?
|
||||
/// Der Titel, der noch darunter liegt und gleich verschwindet.
|
||||
///
|
||||
/// Er wird voll deckend gezeigt, nicht ausgeblendet — das Ausblenden
|
||||
/// übernimmt das neue Bild, indem es ihn zudeckt.
|
||||
@State private var fadingID: String?
|
||||
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
||||
@State private var cover: CGFloat = 1
|
||||
/// Ob noch von selbst weitergewechselt wird.
|
||||
///
|
||||
/// Hört auf, sobald jemand die Fläche berührt. Wer hier die Hand hat,
|
||||
/// fährt selbst — sonst wechselt der Aufmacher genau in der Sekunde, in
|
||||
/// der man nach „Abspielen" greift, und man startet den falschen Film.
|
||||
@State private var isAdvancing = true
|
||||
/// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll
|
||||
/// das Bild auch von der anderen Seite hereinkommen sehen.
|
||||
@State private var isMovingForward = true
|
||||
/// Ob der Zeiger über dem Aufmacher liegt — dann erscheinen die Pfeile.
|
||||
@State private var isPointerInside = false
|
||||
/// Ob er über den Punkten liegt — dann werden sie gross genug zum Treffen.
|
||||
@State private var isHoveringDots = false
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@Environment(TopChrome.self) private var chrome
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
|
||||
/// Wie weit der Aufmacher nach oben unter die Insel greift.
|
||||
///
|
||||
/// Der Inhalt beginnt unter der Insel — das ist für Reihen und Listen
|
||||
/// richtig, für den Aufmacher aber nicht: er ist das Bild, auf dem die
|
||||
/// Insel schweben soll, und ein Bild, das an ihrer Unterkante anfängt,
|
||||
/// lässt darüber einen schwarzen Streifen stehen, den nie etwas füllt.
|
||||
///
|
||||
/// Also nimmt er sich den Platz zurück: um genau diese Strecke höher und
|
||||
/// um genau diese Strecke größer. Unten ändert sich dadurch nichts, die
|
||||
/// Reihen bleiben, wo sie waren.
|
||||
///
|
||||
/// Auf dem iPhone null — dort schwebt nichts über dem Inhalt.
|
||||
private var overlap: CGFloat {
|
||||
sizeClass == .compact ? 0 : chrome.inset
|
||||
}
|
||||
|
||||
/// Der Aufmacher — ein einziger, der sein Bild wechselt.
|
||||
///
|
||||
/// **Nicht zwei übereinander.** So war es, und jeder der beiden brachte
|
||||
/// seinen eigenen Verlauf nach unten mit; zwei halbdurchsichtige Verläufe
|
||||
/// ergeben zusammen keinen ganzen. Gemessen an derselben Stelle im Bild:
|
||||
/// im Ruhezustand 68, in der Mitte des Wechsels 137 — sichtbar als
|
||||
/// Aufhellen genau dort, wo der Titel steht. Jetzt wechselt nur das Bild,
|
||||
/// Verlauf und Text liegen einmal darüber.
|
||||
@ViewBuilder
|
||||
private var heroSection: some View {
|
||||
if items.indices.contains(currentIndex) {
|
||||
HeroSection(
|
||||
item: items[currentIndex],
|
||||
outgoing: fadingID.flatMap { kennung in
|
||||
items.first { $0.id == kennung }
|
||||
},
|
||||
crossfade: cover,
|
||||
incomingOffset: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide)
|
||||
)
|
||||
// Ohne eigene Identität je Titel: die Ansicht **bleibt**, und nur
|
||||
// ihre Angaben wechseln. Eine neue Identität hiesse, sie würde
|
||||
// eingefügt und entfernt — und SwiftUI blendete sie dabei ein,
|
||||
// zusätzlich zur Überblendung, die hier gemeint ist.
|
||||
}
|
||||
}
|
||||
|
||||
private var currentIndex: Int {
|
||||
items.firstIndex { $0.id == shownID } ?? 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: HaloSpacing.regular) {
|
||||
heroSection
|
||||
// Die Höhe: fast das ganze Fenster.
|
||||
//
|
||||
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
|
||||
// breites Vorschaubild — einer über die ganze Höhe dagegen wie die
|
||||
// einzige Seite der App: von den Reihen war nichts mehr zu sehen,
|
||||
// ohne zu scrollen. Zweiundachtzig Prozent lassen die erste Reihe
|
||||
// gerade hereinlugen, und damit weiß man, dass es weitergeht.
|
||||
//
|
||||
// Auf einem schmalen Gerät nicht: dort rechnet sich der Aufmacher
|
||||
// seine Höhe aus der Breite aus — ein Viertel breiter als hoch.
|
||||
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : layout.heroHeight, extra: overlap))
|
||||
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
||||
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
||||
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
||||
// greift, blieb dann rechts genau so viel schwarz übrig, wie er
|
||||
// links gewonnen hatte — auf dem Mac ein Streifen von gut zweihundert
|
||||
// Punkten.
|
||||
// **Nicht** beschneiden: der Aufmacher greift bewusst nach links
|
||||
// über seinen Bereich hinaus, damit sein Bild unter der
|
||||
// Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
|
||||
// und übrig blieb ein schwarzer Streifen neben der Leiste.
|
||||
//
|
||||
// Für den Platz ist es auch nicht nötig: das Hereinrutschen läuft
|
||||
// über `offset`, und das verschiebt nur, was gezeichnet wird.
|
||||
// Wischen blättert weiter, jede Berührung hält das Wechseln an —
|
||||
// auch die, die nur ein Antippen wird. Gleichzeitig, damit weder
|
||||
// dem Wischen noch den Knöpfen darunter etwas weggenommen wird.
|
||||
// Wischen blättert weiter — aber erst ab einem ordentlichen Weg
|
||||
// und nur, wenn es waagerecht gemeint ist.
|
||||
//
|
||||
// Vorher stand hier `minimumDistance: 0`. Damit griff die Geste
|
||||
// sofort und nahm der Seite das senkrechte Scrollen weg: wer auf
|
||||
// dem Aufmacher nach oben wischte, bei dem passierte einfach
|
||||
// nichts. Vierundzwanzig Punkte lassen dem Scrollen den Vortritt.
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 24)
|
||||
.onEnded { value in
|
||||
let width = value.translation.width
|
||||
guard abs(width) > abs(value.translation.height), abs(width) > 40 else { return }
|
||||
isAdvancing = false
|
||||
step(by: width < 0 ? 1 : -1)
|
||||
}
|
||||
)
|
||||
// Das Weiterwechseln hält an, sobald jemand etwas antippt. Getrennt
|
||||
// von der Wischgeste, weil ein Tippen kein Ziehen ist — und weil
|
||||
// eine Geste, die schon bei null Punkten greift, das Scrollen
|
||||
// schluckt.
|
||||
.simultaneousGesture(TapGesture().onEnded { isAdvancing = false })
|
||||
// **Nur auf dem Mac.** Dort gibt es einen Zeiger, und Wischen ist
|
||||
// mit dem Trackpad zwar möglich, aber nicht naheliegend — man
|
||||
// sieht dem Aufmacher nicht an, dass er weitergeht. Auf dem iPad
|
||||
// und dem iPhone wischt man ohnehin, und zwei Knöpfe ständen nur
|
||||
// im Bild.
|
||||
.overlay { if isMac, items.count > 1 { arrows } }
|
||||
|
||||
if items.count > 1 { indicator }
|
||||
}
|
||||
// Zieht den ganzen Aufmacher unter die Insel. Zusammen mit der
|
||||
// zusätzlichen Höhe oben hebt sich das unten auf: er beginnt am
|
||||
// Bildschirmrand und endet, wo er vorher endete.
|
||||
.padding(.top, -overlap)
|
||||
.task(id: shownID) { await advance() }
|
||||
.onChange(of: items.map(\.id), initial: true) { _, ids in
|
||||
// Beim ersten Zeichnen und nach jedem Aktualisieren: steht die
|
||||
// gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn.
|
||||
if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first }
|
||||
}
|
||||
}
|
||||
|
||||
private var isMac: Bool { HaloPlatform.isMac }
|
||||
|
||||
/// Die beiden Pfeile links und rechts.
|
||||
///
|
||||
/// Zurückhaltend: sie sollen im Bild liegen, ohne es zu beherrschen.
|
||||
/// Deshalb erscheinen sie erst, wenn der Zeiger über dem Aufmacher ist —
|
||||
/// vorher gibt es nichts zu bedienen, und ein Knopf, der dauerhaft über
|
||||
/// einem Filmplakat klebt, ist ein Fremdkörper.
|
||||
private var arrows: some View {
|
||||
HStack {
|
||||
arrow(symbol: "chevron.left", direction: -1)
|
||||
Spacer()
|
||||
arrow(symbol: "chevron.right", direction: 1)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.opacity(isPointerInside ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
||||
.onContinuousHover { phase in
|
||||
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func arrow(symbol: String, direction: Int) -> some View {
|
||||
Button {
|
||||
isAdvancing = false
|
||||
step(by: direction)
|
||||
} label: {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(HaloSpacing.regular)
|
||||
.background(.black.opacity(0.45), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(direction > 0 ? "hero.next" : "hero.previous"))
|
||||
}
|
||||
|
||||
/// Die Punkte darunter — auf dem Mac auch ein Bedienelement.
|
||||
///
|
||||
/// **Warum sie wachsen.** Sechs Punkte hoch trifft niemand, und drei
|
||||
/// Punkte breit schon gar nicht. Unter dem Zeiger werden sie deshalb
|
||||
/// grösser, sobald er in ihrer Nähe ist — dieselbe Bewegung, mit der das
|
||||
/// iPad seine Fensterknöpfe unter dem Finger vergrössert. Dass etwas
|
||||
/// wächst, wenn man sich nähert, heisst: hier kann man etwas tun.
|
||||
///
|
||||
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
||||
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
||||
private var indicator: some View {
|
||||
HStack(spacing: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) {
|
||||
ForEach(items.indices, id: \.self) { position in
|
||||
Capsule()
|
||||
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4))
|
||||
.frame(
|
||||
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
||||
height: isEnlarged ? 12 : 6
|
||||
)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
guard isMac else { return }
|
||||
isAdvancing = false
|
||||
jump(to: position)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
||||
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.onContinuousHover { phase in
|
||||
guard isMac else { return }
|
||||
if case .active = phase { isHoveringDots = true } else { isHoveringDots = false }
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: currentIndex)
|
||||
.animation(.snappy(duration: 0.2), value: isEnlarged)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var isEnlarged: Bool { isMac && isHoveringDots }
|
||||
|
||||
/// Springt unmittelbar zu einem Titel.
|
||||
private func jump(to position: Int) {
|
||||
guard position != currentIndex, items.indices.contains(position) else { return }
|
||||
isMovingForward = position > currentIndex
|
||||
fadingID = shownID
|
||||
shownID = items[position].id
|
||||
cover = 0
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
cover = 1
|
||||
} completion: {
|
||||
fadingID = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Einen Titel weiter oder zurück.
|
||||
private func step(by direction: Int) {
|
||||
guard items.count > 1 else { return }
|
||||
let next = (currentIndex + direction + items.count) % items.count
|
||||
|
||||
isMovingForward = direction > 0
|
||||
fadingID = shownID
|
||||
shownID = items[next].id
|
||||
cover = 0
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.55)) {
|
||||
cover = 1
|
||||
} completion: {
|
||||
// Erst wenn oben nichts mehr durchscheint, darf unten etwas
|
||||
// verschwinden. Andersherum gäbe es genau den Moment, in dem man
|
||||
// durch beide hindurch auf den Untergrund sieht.
|
||||
fadingID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func advance() async {
|
||||
guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return }
|
||||
|
||||
// Das nächste Bild schon holen, solange das aktuelle noch steht. Ohne
|
||||
// das blendet der Aufmacher auf eine graue Fläche über und das Bild
|
||||
// erscheint erst danach — beim Blättern fiel das nicht auf, beim
|
||||
// Überblenden sofort.
|
||||
prefetchNeighbours()
|
||||
|
||||
try? await Task.sleep(for: Self.dwell)
|
||||
guard !Task.isCancelled, isAdvancing else { return }
|
||||
step(by: 1)
|
||||
}
|
||||
|
||||
private func prefetchNeighbours() {
|
||||
guard let backend = session.backend, !items.isEmpty else { return }
|
||||
let item = items[(currentIndex + 1) % items.count]
|
||||
let compact = sizeClass == .compact
|
||||
|
||||
// Hintergrundbild und Schriftzug zusammen: fehlt der Schriftzug beim
|
||||
// Überblenden, steht dort erst der gesetzte Titel und das Logo springt
|
||||
// eine Sekunde später hinterher — zwei Bewegungen, wo eine sein soll.
|
||||
let urls = [
|
||||
HeroArtwork.backdropURL(for: item, backend: backend, compact: compact),
|
||||
HeroArtwork.logoURL(for: item, backend: backend),
|
||||
]
|
||||
|
||||
for url in urls.compactMap(\.self) {
|
||||
Task { _ = await ArtworkLoader.shared.image(for: url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt dem Aufmacher einen Anteil an der sichtbaren Höhe — oder gar nichts.
|
||||
///
|
||||
/// Eigener Baustein, weil `containerRelativeFrame` sich nicht abschalten lässt:
|
||||
/// es wirkt, sobald es dasteht. Ein `if` in der Ansicht brächte den beiden
|
||||
/// Zweigen außerdem verschiedene Identitäten ein, und der Aufmacher würde beim
|
||||
/// Drehen neu geladen statt mitzuwandern.
|
||||
private struct HeroHeight: ViewModifier {
|
||||
let fraction: CGFloat?
|
||||
/// Was über den Anteil hinaus dazukommt: der Platz unter der Insel, den
|
||||
/// der Aufmacher mitfüllt, weil er nach oben unter sie greift.
|
||||
let extra: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let fraction {
|
||||
content.containerRelativeFrame(.vertical) { height, _ in height * fraction + extra }
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Der Aufmacher oben auf der Startseite.
|
||||
struct HeroSection: View {
|
||||
let item: MediaItem
|
||||
/// Der Titel, dessen Bild gerade verschwindet — nur im Karussell gesetzt.
|
||||
var outgoing: MediaItem?
|
||||
/// Wie weit das neue Bild schon deckt.
|
||||
var crossfade: CGFloat = 1
|
||||
/// Wie weit es dabei hereinrutscht.
|
||||
var incomingOffset: CGFloat = 0
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: metadata,
|
||||
summary: item.summary,
|
||||
artworkURLs: session.backend.map {
|
||||
HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
|
||||
} ?? [],
|
||||
outgoingArtworkURLs: session.backend.flatMap { backend in
|
||||
outgoing.map { HeroArtwork.backdropURLs(for: $0, backend: backend, compact: sizeClass == .compact) }
|
||||
} ?? [],
|
||||
outgoingTitle: outgoing?.rowTitle,
|
||||
outgoingMetadata: outgoing.flatMap(Self.metadata(for:)),
|
||||
outgoingLogoURL: session.backend.flatMap { backend in
|
||||
outgoing.flatMap { HeroArtwork.logoURL(for: $0, backend: backend) }
|
||||
},
|
||||
crossfade: crossfade,
|
||||
incomingOffset: incomingOffset,
|
||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||
// Auf dem iPhone 4:3 statt 16:9: hochkant ließ 16:9 dem Text so
|
||||
// wenig Platz, dass Schriftzug und Beschreibung übereinanderlagen.
|
||||
// Beschnitten statt hochkant — das Bild bleibt ein Querbild.
|
||||
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
|
||||
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
|
||||
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
|
||||
// vorschreibt, und darüber und darunter bliebe der halbe
|
||||
// Bildschirm leer.
|
||||
fillsAvailableSpace: sizeClass != .compact
|
||||
) {
|
||||
Button {
|
||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||
} label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||
Label("hero.details", systemImage: "info.circle")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||
private var playLabel: LocalizedStringKey {
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||
}
|
||||
|
||||
private var metadata: String? { Self.metadata(for: item) }
|
||||
|
||||
/// Die Zeile unter dem Titel.
|
||||
///
|
||||
/// `static`, weil sie während eines Wechsels auch für den weichenden
|
||||
/// Titel gebraucht wird — und der ist nicht `item`.
|
||||
static func metadata(for item: MediaItem) -> String? {
|
||||
var pieces: [String] = []
|
||||
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
|
||||
pieces.append(String(localized: "episode.short \(season) \(episode)"))
|
||||
}
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
|
||||
if let rating = item.contentRating { pieces.append(rating) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
///
|
||||
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
|
||||
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
|
||||
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
||||
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
||||
struct HomeView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@State private var feed = HomeFeed()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch feed.state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
}
|
||||
|
||||
case .loaded:
|
||||
let hubs = feed.rows(for: layout)
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
} description: {
|
||||
Text("home.empty.description")
|
||||
}
|
||||
} else {
|
||||
content(hubs: hubs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
||||
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
||||
// ohnehin mehr.
|
||||
.navigationTitle("")
|
||||
.haloInlineTitle()
|
||||
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
||||
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
||||
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
||||
// Aufmacher.
|
||||
.haloClearToolbarBackground()
|
||||
// Gar keine Navigationsleiste auf der Startseite.
|
||||
//
|
||||
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
||||
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
|
||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
||||
.refreshable { await feed.load(from: session.backend) }
|
||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||
.environment(\.haloItemChanged, { await feed.load(from: session.backend) })
|
||||
}
|
||||
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(row: hub)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let row: MediaRowGroup
|
||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
||||
var opensDetails: Bool = false
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
|
||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||
private var landscapeWidth: CGFloat { 220 * layout.cardScale }
|
||||
/// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate
|
||||
/// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck.
|
||||
///
|
||||
/// Beide mit demselben Faktor: ihr Verhältnis zueinander soll stimmen,
|
||||
/// egal wie groß es insgesamt eingestellt ist.
|
||||
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text(row.title)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: HaloSpacing.regular) {
|
||||
ForEach(row.items) { item in
|
||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
||||
// bleibt an beliebiger Stelle stehen.
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
|
||||
/// Szenenbild beim Weiterschauen, sonst das Plakat.
|
||||
///
|
||||
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat —
|
||||
/// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als
|
||||
/// Nächstes sehen will, sucht nach Plakaten.
|
||||
@ViewBuilder
|
||||
private func card(for item: MediaItem) -> some View {
|
||||
if row.isContinueWatching {
|
||||
LandscapeCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.rowArtworkPath,
|
||||
width: 440,
|
||||
height: 248
|
||||
),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
.frame(width: landscapeWidth)
|
||||
} else {
|
||||
PosterCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(
|
||||
path: item.posterArtworkPath,
|
||||
width: 300,
|
||||
height: 450
|
||||
),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
.frame(width: posterWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
||||
///
|
||||
/// **Was hier wegfällt, ist ein Zwischenschritt.** Vorher stand an dieser
|
||||
/// Stelle eine Liste aller Mediatheken; wer Filme sehen wollte, tippte auf
|
||||
/// „Bibliothek", dann auf „Filme", und bekam erst dann ein Raster. Zurück und
|
||||
/// in eine andere hiess: zweimal tippen. Der Zwischenbildschirm hat nie etwas
|
||||
/// erzählt, was die Leiste nicht auch zeigen kann.
|
||||
///
|
||||
/// Auf dem iPad und am Mac gibt es ihn ohnehin nicht — dort steht jede
|
||||
/// Mediathek einzeln in der Seitenleiste, und auf dem Fernseher nebeneinander
|
||||
/// in der oberen Leiste. Das hier ist dieselbe Idee in der Form, die ein
|
||||
/// Telefon hergibt.
|
||||
///
|
||||
/// **Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat
|
||||
/// fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis,
|
||||
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
||||
/// gezeichnete Leiste kennt diese Grenze nicht.
|
||||
struct LibraryBrowser: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
/// Was gerade gezeigt wird.
|
||||
///
|
||||
/// `nil` heisst „noch nichts gewählt" — dann gilt die erste Mediathek.
|
||||
/// Eine Vorauswahl beim Anlegen ginge nicht: die Mediatheken kommen vom
|
||||
/// Server und stehen beim ersten Aufbau noch nicht fest.
|
||||
@State private var auswahl: Auswahl?
|
||||
|
||||
private enum Auswahl: Hashable {
|
||||
case library(String)
|
||||
case liveTV
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
inhalt
|
||||
.background(Color.haloCanvas)
|
||||
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
||||
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
||||
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if eintraege.count > 1 { leiste }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var inhalt: some View {
|
||||
switch aktuell {
|
||||
case .library(let id):
|
||||
if let library = session.libraries.first(where: { $0.id == id }) {
|
||||
// **Mit `id`, damit der Wechsel sauber neu aufsetzt.** Ohne
|
||||
// das behielte die Ansicht die Titel der vorigen Mediathek,
|
||||
// bis der Server antwortet — und für einen Augenblick stünden
|
||||
// Filme unter „Serien".
|
||||
LibraryView(library: library).id(library.id)
|
||||
}
|
||||
case .liveTV:
|
||||
LiveTVView()
|
||||
case nil:
|
||||
ContentUnavailableView {
|
||||
Label("libraryHub.empty", systemImage: "books.vertical")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Leiste
|
||||
|
||||
private var leiste: some View {
|
||||
// **So breit wie nötig, nicht wie möglich.** Über die ganze Breite
|
||||
// gezogen stand sie linksbündig in einer halbleeren Kapsel — eine
|
||||
// Leiste, die dreimal so lang ist wie ihr Inhalt, sieht aus wie ein
|
||||
// Fehler im Layout.
|
||||
//
|
||||
// `ViewThatFits` nimmt die erste Fassung, die hineinpasst: die
|
||||
// ungescrollte Reihe, solange sie passt, sonst die scrollbare. Damit
|
||||
// ist beides richtig, ohne dass irgendwo eine Zahl stünde, ab wann es
|
||||
// kippt — bei vier Mediatheken passt es, bei acht nicht, und keine
|
||||
// Fassung muss das wissen.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
reihe
|
||||
ScrollView(.horizontal) { reihe }
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
||||
// kein nachgebautes Aussehen.
|
||||
.background { HaloSidebar.surface(in: .capsule) }
|
||||
.clipShape(.capsule)
|
||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.bottom, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
private var reihe: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(eintraege, id: \.auswahl) { eintrag in
|
||||
pille(eintrag)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
private func pille(_ eintrag: Eintrag) -> some View {
|
||||
let gewaehlt = eintrag.auswahl == aktuell
|
||||
|
||||
return Button {
|
||||
auswahl = eintrag.auswahl
|
||||
} label: {
|
||||
Label {
|
||||
Text(eintrag.titel).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: eintrag.symbol)
|
||||
}
|
||||
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
||||
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary)
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if gewaehlt { Capsule().fill(Color.haloElevated) }
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
||||
}
|
||||
|
||||
// MARK: - Was in der Leiste steht
|
||||
|
||||
private struct Eintrag {
|
||||
let auswahl: Auswahl
|
||||
let titel: String
|
||||
let symbol: String
|
||||
}
|
||||
|
||||
private var eintraege: [Eintrag] {
|
||||
var liste = session.libraries.map {
|
||||
Eintrag(auswahl: .library($0.id), titel: $0.title, symbol: $0.kind.symbol)
|
||||
}
|
||||
// Live-TV wohnte im Zwischenbildschirm und bräuchte sonst ein neues
|
||||
// Zuhause. Hier steht es, wo man es sucht: neben den Mediatheken.
|
||||
if session.supportsLiveTV {
|
||||
liste.append(Eintrag(
|
||||
auswahl: .liveTV,
|
||||
titel: String(localized: "sidebar.liveTV"),
|
||||
symbol: MediaLibrary.Kind.liveTV.symbol
|
||||
))
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/// Die gültige Auswahl — die gespeicherte, solange es sie noch gibt.
|
||||
///
|
||||
/// **Nicht einfach `auswahl`.** Nach einem Serverwechsel zeigt die
|
||||
/// gespeicherte Kennung auf eine Mediathek, die es nicht mehr gibt; dann
|
||||
/// stünde ein leerer Bildschirm da, ohne dass irgendetwas erklärt, warum.
|
||||
private var aktuell: Auswahl? {
|
||||
if let auswahl, eintraege.contains(where: { $0.auswahl == auswahl }) {
|
||||
return auswahl
|
||||
}
|
||||
return eintraege.first?.auswahl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Filter und Sortierung einer Mediathek.
|
||||
struct LibraryFilterSheet: View {
|
||||
@Binding var query: MediaQuery
|
||||
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||
///
|
||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
||||
/// Mediathek dieser Art nachgeschlagen — bei zwei Filmmediatheken also
|
||||
/// über die falsche. Die Sprache je Mediathek braucht die Kennung
|
||||
/// ohnehin, und damit verschwindet die Suche nach der Art gleich mit.
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var genres: [MediaFilterOption] = []
|
||||
|
||||
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
||||
///
|
||||
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||
@State private var draft: MediaQuery
|
||||
|
||||
init(query: Binding<MediaQuery>, library: MediaLibrary) {
|
||||
self._query = query
|
||||
self.library = library
|
||||
self._draft = State(initialValue: query.wrappedValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("filter.sort") {
|
||||
Picker("filter.sort", selection: $draft.sort) {
|
||||
ForEach(MediaQuery.Sort.allCases, id: \.self) { sort in
|
||||
Text(sort.label).tag(sort)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
|
||||
Section("filter.filters") {
|
||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||
|
||||
Picker("filter.genre", selection: $draft.genre) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
ForEach(genres) { genre in
|
||||
Text(genre.title).tag(String?.some(genre.id))
|
||||
}
|
||||
}
|
||||
|
||||
Picker("filter.resolution", selection: $draft.resolution) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
Text("4K").tag(String?.some("4k"))
|
||||
Text("Full HD").tag(String?.some("1080"))
|
||||
Text("HD").tag(String?.some("720"))
|
||||
Text("SD").tag(String?.some("sd"))
|
||||
}
|
||||
}
|
||||
|
||||
// **Kein Filter, und deshalb ein eigener Abschnitt.** Ein
|
||||
// Filter schränkt ein, was zu sehen ist; das hier bestimmt,
|
||||
// was zu hören ist. Zwischen die Filter gestellt läse es sich
|
||||
// als „nur Titel mit deutscher Tonspur zeigen".
|
||||
Section {
|
||||
AudioLanguagePicker(
|
||||
scope: .library(library.id),
|
||||
title: "languages.forThisLibrary"
|
||||
)
|
||||
} header: {
|
||||
Text("filter.librarySettings")
|
||||
} footer: {
|
||||
Text("languages.forThisLibraryExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("filter.reset", role: .destructive) {
|
||||
draft = MediaQuery(sort: draft.sort)
|
||||
}
|
||||
.disabled(!draft.isFiltering)
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("filter.title"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("action.cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.apply") {
|
||||
query = draft
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadGenres() }
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
|
||||
private func loadGenres() async {
|
||||
guard let server = session.backend else { return }
|
||||
genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaQuery.Sort {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .titleAscending: "sort.titleAscending"
|
||||
case .titleDescending: "sort.titleDescending"
|
||||
case .recentlyAdded: "sort.recentlyAdded"
|
||||
case .releaseDate: "sort.releaseDate"
|
||||
case .rating: "sort.rating"
|
||||
case .lastPlayed: "sort.lastPlayed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Eine Mediathek zum Durchblättern.
|
||||
struct LibraryView: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||
/// zwischen Raster und Liste.
|
||||
@Environment(HomeLayoutSettings.self) private var sizing
|
||||
@AppStorage("halo.library.layout") private var layout: LibraryLayout = .grid
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@State private var items: [MediaItem] = []
|
||||
@State private var state: LoadState<Void> = .loading
|
||||
@State private var query = MediaQuery()
|
||||
@State private var page: MediaPage?
|
||||
/// Ob der letzte Nachladen misslungen ist.
|
||||
///
|
||||
/// Dann steht unten eine Zeile zum Weiterversuchen: von selbst passiert
|
||||
/// nichts mehr, weil die Kacheln, die das Nachladen auslösen, längst
|
||||
/// erschienen sind und kein zweites Mal erscheinen.
|
||||
@State private var loadMoreFailed = false
|
||||
@State private var isLoadingMore = false
|
||||
@State private var showsFilters = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading where items.isEmpty:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message) where items.isEmpty:
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await reload() } }
|
||||
}
|
||||
|
||||
default:
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
|
||||
} description: {
|
||||
Text("library.empty.description")
|
||||
} actions: {
|
||||
if query.isFiltering {
|
||||
Button("library.clearFilters") {
|
||||
query = MediaQuery(sort: query.sort)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contents
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(library.title)
|
||||
.haloInlineTitle()
|
||||
.toolbar { toolbar }
|
||||
.sheet(isPresented: $showsFilters) {
|
||||
LibraryFilterSheet(query: $query, library: library)
|
||||
}
|
||||
.task(id: library.id) {
|
||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
||||
// `reload()` die Titel der vorigen für die eigenen und fragt den
|
||||
// Speicher gar nicht erst.
|
||||
items = []
|
||||
page = nil
|
||||
await reload()
|
||||
}
|
||||
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
|
||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||
// verschiedenen Abfragen vermischen.
|
||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||
.refreshable { await reload() }
|
||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||
.environment(\.haloItemChanged, { await reload() })
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contents: some View {
|
||||
switch layout {
|
||||
case .grid: grid
|
||||
case .list: list
|
||||
}
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
||||
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
||||
// ohne dass die Größenklasse abgefragt werden muss.
|
||||
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)],
|
||||
spacing: HaloSpacing.loose
|
||||
) {
|
||||
ForEach(items) { item in
|
||||
QuickPlayTile(item: item) {
|
||||
PosterCard(
|
||||
title: item.title,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
}
|
||||
.task { await loadMoreIfNeeded(reaching: item) }
|
||||
}
|
||||
|
||||
// Was noch kommt, steht schon da — in der Form, die es haben
|
||||
// wird. Das beantwortet die Frage, die ein aufhörendes Raster
|
||||
// offen lässt: war das alles?
|
||||
ForEach(0..<pendingCount, id: \.self) { position in
|
||||
PlaceholderCard(index: position)
|
||||
}
|
||||
}
|
||||
.padding(HaloSpacing.screenMargin)
|
||||
|
||||
moreButton.padding(.bottom, HaloSpacing.section)
|
||||
footerCount.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||
LibraryListRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task { await loadMoreIfNeeded(reaching: item) }
|
||||
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
}
|
||||
|
||||
if pendingCount > 0 {
|
||||
ProgressView()
|
||||
.padding(.vertical, HaloSpacing.section)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
moreButton.padding(.vertical, HaloSpacing.section)
|
||||
footerCount.padding(.bottom, HaloSpacing.section)
|
||||
}
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
||||
///
|
||||
/// Beide Server nennen die Gesamtzahl: Jellyfin als `TotalRecordCount`,
|
||||
/// Plex als `totalSize`. Damit ist die Frage „war das alles?" beantwortbar,
|
||||
/// statt dass man am Ende des Rasters rät.
|
||||
///
|
||||
/// Gedeckelt, weil zweihundert Platzhalter niemandem helfen: ein Dutzend
|
||||
/// sagt „es geht weiter" genauso gut und hält die Liste flüssig.
|
||||
private var pendingCount: Int {
|
||||
guard !loadMoreFailed, let page, page.hasMore else { return 0 }
|
||||
return min(page.totalCount - items.count, 12)
|
||||
}
|
||||
|
||||
/// Die Zeile am Ende, wenn wirklich alles da ist.
|
||||
///
|
||||
/// Ohne sie bleibt der Zweifel: ein Raster, das aufhört, sieht genauso
|
||||
/// aus wie eines, das noch lädt.
|
||||
@ViewBuilder
|
||||
private var footerCount: some View {
|
||||
if let page, !page.hasMore, !items.isEmpty {
|
||||
Text("library.allLoaded \(items.count)")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Weg weiter, wenn das Nachladen hängen geblieben ist.
|
||||
@ViewBuilder
|
||||
private var moreButton: some View {
|
||||
if loadMoreFailed, let page, page.hasMore {
|
||||
VStack(spacing: HaloSpacing.snug) {
|
||||
Text("library.loadMoreFailed")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Button("library.loadMore") {
|
||||
Task { await loadMore() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
// Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag
|
||||
// in der Leiste unten. Die Begründung dafür war lange, die fünf
|
||||
// Plätze würden für die Mediatheken gebraucht — seit die in einer
|
||||
// eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben
|
||||
// ist der bessere Grund: gesucht wird in einer Mediathek, und dort
|
||||
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
|
||||
// und ein zweiter Weg dorthin wäre nur Verdopplung.
|
||||
if sizeClass == .compact {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
NavigationLink(value: SearchRoute()) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.accessibilityLabel(Text("library.search"))
|
||||
}
|
||||
}
|
||||
|
||||
// **Neu laden.** Auf dem Mac und dem iPad gibt es kein Ziehen von
|
||||
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
|
||||
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
|
||||
// Knopf und keine Geste.
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button {
|
||||
Task { await reload() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityLabel(Text("library.reload"))
|
||||
.disabled(isLoadingMore)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button {
|
||||
showsFilters = true
|
||||
} label: {
|
||||
Image(systemName: query.isFiltering
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("library.filters"))
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button {
|
||||
layout = layout == .grid ? .list : .grid
|
||||
} label: {
|
||||
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
|
||||
}
|
||||
.accessibilityLabel(Text("library.toggleLayout"))
|
||||
}
|
||||
|
||||
// **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg
|
||||
// in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr
|
||||
// gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen —
|
||||
// SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der
|
||||
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
||||
// schreibt.
|
||||
if sizeClass == .compact {
|
||||
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Laden
|
||||
|
||||
/// Lädt die erste Seite — erst aus dem Speicher, dann vom Server.
|
||||
///
|
||||
/// **Der gespeicherte Stand zuerst, ohne Ladekreis.** Die Plakate liegen
|
||||
/// ohnehin schon auf der Platte; was fehlte, war die Liste, die sagt,
|
||||
/// welche davon hierhergehören. Mit ihr steht die Mediathek sofort da.
|
||||
///
|
||||
/// **Dann der Server, stillschweigend.** Kein zweiter Ladekreis über einer
|
||||
/// Liste, die schon zu sehen ist — die Titel werden ersetzt, sobald die
|
||||
/// Antwort da ist, und in aller Regel sieht man davon nichts, weil sich
|
||||
/// nichts geändert hat.
|
||||
///
|
||||
/// **Und wenn der Server nicht antwortet**, bleibt der gespeicherte Stand
|
||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||
/// Fehlerbildschirm.
|
||||
private func reload() async {
|
||||
guard let server = session.backend else { return }
|
||||
let schlüssel = query.cacheKey
|
||||
let kennung = server.identity
|
||||
|
||||
// Nur wenn nichts dasteht. Beim Herunterziehen und beim Filterwechsel
|
||||
// ist das Gezeigte aktueller als jeder Abzug.
|
||||
if items.isEmpty,
|
||||
let stand = await LibraryCache.shared.snapshot(
|
||||
server: kennung, library: library.id, queryKey: schlüssel
|
||||
) {
|
||||
items = stand.items
|
||||
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
|
||||
state = .loaded(())
|
||||
} else if items.isEmpty {
|
||||
state = .loading
|
||||
}
|
||||
|
||||
do {
|
||||
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
|
||||
items = first.items
|
||||
page = first
|
||||
loadMoreFailed = false
|
||||
state = .loaded(())
|
||||
|
||||
await LibraryCache.shared.store(
|
||||
LibraryCache.Snapshot(
|
||||
queryKey: schlüssel, items: first.items, totalCount: first.totalCount
|
||||
),
|
||||
server: kennung,
|
||||
library: library.id
|
||||
)
|
||||
} catch {
|
||||
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
|
||||
// sich als Übertragungsfehler, nicht als Abbruch.
|
||||
if Task.isCancelled { return }
|
||||
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
|
||||
// bleibt es. Den Fehler über eine brauchbare Liste zu legen
|
||||
// nähme dem Nutzer etwas weg, statt ihm etwas zu sagen.
|
||||
guard items.isEmpty else { return }
|
||||
state = .failed(message: ErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||
guard
|
||||
// Fünf Einträge vor Schluss: früh genug, dass das Nachgeladene da
|
||||
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
|
||||
// jedes Scrollen eine Anfrage auslöst.
|
||||
items.suffix(5).contains(where: { $0.id == item.id }),
|
||||
// Nach einem Fehlschlag **nicht** von selbst: sonst läuft bei
|
||||
// jedem Scrollen eine Anfrage in dieselbe tote Leitung. Von dort
|
||||
// übernimmt der Knopf.
|
||||
!loadMoreFailed
|
||||
else { return }
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
/// Holt die nächste Seite — von selbst oder auf Knopfdruck.
|
||||
private func loadMore() async {
|
||||
guard let server = session.backend,
|
||||
let page, page.hasMore,
|
||||
!isLoadingMore
|
||||
else { return }
|
||||
|
||||
isLoadingMore = true
|
||||
loadMoreFailed = false
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let next = try await server.items(
|
||||
inLibrary: library,
|
||||
query: query,
|
||||
offset: page.nextOffset,
|
||||
limit: 60
|
||||
)
|
||||
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
|
||||
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
|
||||
// und SwiftUI beschwert sich über doppelte Kennungen.
|
||||
let known = Set(items.map(\.id))
|
||||
items.append(contentsOf: next.items.filter { !known.contains($0.id) })
|
||||
self.page = next
|
||||
} catch {
|
||||
// **Nicht `page = nil`.** Das stand hier, und es beendete das
|
||||
// Nachladen **endgültig**: eine einzige misslungene Anfrage auf
|
||||
// einer wackeligen Leitung, und die Mediathek blieb für immer bei
|
||||
// sechzig Titeln stehen — lautlos, ohne dass irgendetwas darauf
|
||||
// hindeutete. Genau so sieht „es sind nicht alle Filme drin" aus.
|
||||
//
|
||||
// Die Seite bleibt also, damit ein zweiter Versuch möglich ist.
|
||||
// Ein Fehler-Sheet mitten im Scrollen wäre trotzdem störend —
|
||||
// stattdessen erscheint unten eine Zeile zum Weiterladen.
|
||||
loadMoreFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LibraryLayout: String, CaseIterable {
|
||||
case grid, list
|
||||
}
|
||||
|
||||
/// Eine Zeile in der Listenansicht.
|
||||
private struct LibraryListRow: View {
|
||||
let item: MediaItem
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
aspectRatio: PosterCard.aspectRatio
|
||||
)
|
||||
.frame(width: 44)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = item.cardSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.accessibilityLabel(Text("item.watched"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Welche Sender überhaupt angezeigt werden.
|
||||
///
|
||||
/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer
|
||||
/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an.
|
||||
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
|
||||
/// die Ansicht unübersichtlich.
|
||||
struct ChannelSelectionSheet: View {
|
||||
let channels: [LiveChannel]
|
||||
@Binding var selection: Set<String>
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(channels) { channel in
|
||||
row(for: channel)
|
||||
}
|
||||
} footer: {
|
||||
Text("liveTV.selection.hint")
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("liveTV.selection"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .haloLeading) {
|
||||
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
||||
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
||||
// wäre verwirrend.
|
||||
Button("liveTV.selection.all") { selection.removeAll() }
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for channel: LiveChannel) -> some View {
|
||||
Button {
|
||||
// Beim ersten Antippen aus „alle" eine echte Auswahl machen:
|
||||
// sonst hakt man einen Sender an und alle anderen bleiben
|
||||
// trotzdem sichtbar.
|
||||
if selection.isEmpty {
|
||||
selection = Set(channels.map(\.id)).subtracting([channel.id])
|
||||
} else if selection.contains(channel.id) {
|
||||
selection.remove(channel.id)
|
||||
} else {
|
||||
selection.insert(channel.id)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: HaloAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 48, height: 29)
|
||||
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
Text(channel.title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func isShown(_ channel: LiveChannel) -> Bool {
|
||||
ChannelSelection.shows(channel, selection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Regel, nach der ausgewählt wird.
|
||||
///
|
||||
/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der
|
||||
/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet
|
||||
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
|
||||
/// angehakt zu haben.
|
||||
enum ChannelSelection {
|
||||
static func shows(_ channel: LiveChannel, selection: Set<String>) -> Bool {
|
||||
selection.isEmpty || selection.contains(channel.id)
|
||||
}
|
||||
|
||||
static func apply(_ selection: Set<String>, to channels: [LiveChannel]) -> [LiveChannel] {
|
||||
guard !selection.isEmpty else { return channels }
|
||||
let kept = channels.filter { selection.contains($0.id) }
|
||||
// Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine
|
||||
// leere Liste stehen, ohne dass er es ändern könnte.
|
||||
return kept.isEmpty ? channels : kept
|
||||
}
|
||||
|
||||
/// Die Auswahl als Text, wie sie in den Einstellungen liegt.
|
||||
static func decode(_ stored: String) -> Set<String> {
|
||||
Set(stored.split(separator: "\n").map(String.init))
|
||||
}
|
||||
|
||||
static func encode(_ selection: Set<String>) -> String {
|
||||
selection.sorted().joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Live-TV mit Programmführer.
|
||||
struct LiveTVView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
|
||||
@State private var availability: LiveTVAvailability?
|
||||
@State private var channels: [LiveChannel] = []
|
||||
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
||||
@State private var channelSource: String?
|
||||
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
||||
@State private var trace: [LiveChannelProbe] = []
|
||||
@State private var programs: [String: [LiveProgram]] = [:]
|
||||
@State private var isTuning: String?
|
||||
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
||||
@State private var tuneFailure: TuneFailure?
|
||||
/// Raster, Senderliste oder Aufnahmen.
|
||||
///
|
||||
/// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
|
||||
/// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
|
||||
/// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
|
||||
///
|
||||
/// Neuer Schlüssel statt des alten Wahrheitswerts: aus „zeigt das Raster"
|
||||
/// ist eine Wahl aus dreien geworden, und die beiden ineinander zu
|
||||
/// überführen wäre mehr Aufwand, als einmal beim Raster zu beginnen.
|
||||
@AppStorage("liveTV.mode") private var mode: LiveTVMode = .guide
|
||||
/// Welche Sender gezeigt werden. Leer heißt alle.
|
||||
@AppStorage("liveTV.selection") private var storedSelection = ""
|
||||
@State private var showsSelection = false
|
||||
|
||||
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
|
||||
/// Liste nicht auseinanderlaufen.
|
||||
private var shownChannels: [LiveChannel] {
|
||||
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch availability {
|
||||
case nil:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .notConfigured:
|
||||
// Der häufigste Fall — Live-TV verlangt eine Fernsehkarte oder
|
||||
// einen Netzwerk-Tuner am Server. Ein Fehlerdialog wäre hier
|
||||
// irreführend: es ist nichts kaputt, es ist nur nichts da.
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.notConfigured.title", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
} description: {
|
||||
Text("liveTV.notConfigured.description")
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
case .available where channels.isEmpty && mode != .recordings:
|
||||
// Empfänger gefunden, aber keine Sender. Ein anderer
|
||||
// Zustand als „nicht eingerichtet", und die Unterscheidung
|
||||
// zählt: sie sagt, ob es am Server liegt oder an der App.
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.noChannels.title", systemImage: "tv")
|
||||
} description: {
|
||||
Text("liveTV.noChannels.description")
|
||||
} actions: {
|
||||
VStack(spacing: HaloSpacing.regular) {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
searchTrace
|
||||
}
|
||||
}
|
||||
|
||||
case .available:
|
||||
switch mode {
|
||||
case .guide:
|
||||
ProgramGuide(
|
||||
channels: shownChannels,
|
||||
programs: programs,
|
||||
tuning: isTuning,
|
||||
artwork: { channel in
|
||||
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
|
||||
},
|
||||
onTune: { channel, program in Task { await tune(channel, program: program) } },
|
||||
// Beide nur, wenn der Server aufnehmen kann. Sonst
|
||||
// `nil`, und die Knöpfe erscheinen gar nicht erst.
|
||||
onRecord: session.supportsRecordings
|
||||
? { program in Task { await record(program, series: false) } }
|
||||
: nil,
|
||||
onRecordSeries: session.supportsRecordings
|
||||
? { program in Task { await record(program, series: true) } }
|
||||
: nil
|
||||
)
|
||||
case .channels:
|
||||
channelList
|
||||
case .recordings:
|
||||
RecordingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.liveTV"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
if case .available = availability, !channels.isEmpty {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button {
|
||||
showsSelection = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("liveTV.selection"))
|
||||
}
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Picker("liveTV.mode", selection: $mode) {
|
||||
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
||||
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
||||
// Der dritte Schalter fehlt schlicht, wo der Server
|
||||
// nicht aufnehmen kann — bei Plex etwa.
|
||||
if session.supportsRecordings {
|
||||
Image(systemName: "recordingtape").tag(LiveTVMode.recordings)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Ein gespeicherter Modus, den dieser Server nicht kennt, führte
|
||||
// sonst auf einen Schalter, den es nicht gibt — und damit auf
|
||||
// einen leeren Bildschirm ohne Weg zurück.
|
||||
if mode == .recordings, !session.supportsRecordings { mode = .guide }
|
||||
await load()
|
||||
}
|
||||
.refreshable { await load() }
|
||||
.sheet(isPresented: $showsSelection) {
|
||||
ChannelSelectionSheet(
|
||||
channels: channels,
|
||||
selection: Binding(
|
||||
get: { ChannelSelection.decode(storedSelection) },
|
||||
set: { storedSelection = ChannelSelection.encode($0) }
|
||||
)
|
||||
)
|
||||
}
|
||||
// Ein Sheet statt eines Dialogs: die Antwort des Servers ist
|
||||
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
|
||||
// wieder nur, dass es nicht ging.
|
||||
.sheet(item: $tuneFailure) { failure in
|
||||
TuneFailureSheet(failure: failure)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wo überall gesucht wurde, und was dort stand.
|
||||
///
|
||||
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
|
||||
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
|
||||
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
|
||||
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
|
||||
@ViewBuilder
|
||||
private var searchTrace: some View {
|
||||
if !trace.isEmpty {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
if let channelSource {
|
||||
Text(channelSource)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(trace) { probe in
|
||||
Text("\(probe.path) → \(probe.result)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(2)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
|
||||
Button {
|
||||
HaloClipboard.copy(
|
||||
trace
|
||||
.map { "\($0.path) → \($0.result)" }
|
||||
.joined(separator: "\n")
|
||||
)
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(shownChannels) { channel in
|
||||
ChannelRow(
|
||||
channel: channel,
|
||||
programs: programs[channel.id] ?? [],
|
||||
isTuning: isTuning == channel.id,
|
||||
onTune: { Task { await tune(channel) } }
|
||||
)
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let liveTV = session.liveTV else { return }
|
||||
|
||||
let availability = await liveTV.liveTVAvailability()
|
||||
self.availability = availability
|
||||
|
||||
guard case .available = availability else { return }
|
||||
|
||||
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
|
||||
// antwortet auf manchen Servern gar nicht, und dann tragen die
|
||||
// Rastereinträge die Sender selbst. Getrennt geholt passten die
|
||||
// Kennungen zudem nicht zueinander, und das Programm landete in
|
||||
// keiner Zeile.
|
||||
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
|
||||
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
|
||||
// und über Mobilfunk wartet man darauf.
|
||||
let listing = await liveTV.liveChannels()
|
||||
channels = listing.channels
|
||||
channelSource = listing.source
|
||||
trace = listing.probes
|
||||
|
||||
let now = Date()
|
||||
programs = (try? await liveTV.livePrograms(
|
||||
channelIDs: listing.channels.map(\.id),
|
||||
from: now.addingTimeInterval(-3_600),
|
||||
to: now.addingTimeInterval(6 * 3_600)
|
||||
)) ?? [:]
|
||||
}
|
||||
|
||||
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
|
||||
guard let liveTV = session.liveTV, isTuning == nil else { return }
|
||||
isTuning = channel.id
|
||||
defer { isTuning = nil }
|
||||
|
||||
// Die laufende Sendung mitgeben: über ihre Adresse beim
|
||||
// Programmanbieter führt der direkte Weg zum Stream.
|
||||
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||
let policy = settings.policy(for: network)
|
||||
|
||||
let result = await liveTV.tune(
|
||||
channel: channel,
|
||||
program: running,
|
||||
maximumBitrate: policy.maximumBitrate
|
||||
)
|
||||
|
||||
guard let stream = result.url else {
|
||||
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
||||
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
||||
tuneFailure = TuneFailure(
|
||||
channel: channel.title,
|
||||
reason: String(localized: "liveTV.noStream"),
|
||||
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
|
||||
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
|
||||
playback.playLive(
|
||||
channelID: channel.id,
|
||||
title: channel.title,
|
||||
programme: running?.title,
|
||||
programmeEndsAt: running?.endsAt,
|
||||
stream: stream,
|
||||
subscriptionKey: result.subscriptionKey
|
||||
)
|
||||
}
|
||||
|
||||
/// Merkt eine Sendung vor.
|
||||
///
|
||||
/// Ohne Rückmeldung bei Erfolg: der Knopf verschwindet, sobald die
|
||||
/// Sendung einen Timer trägt, und das ist die Antwort. Eine Meldung
|
||||
/// obendrauf wäre eine zweite.
|
||||
private func record(_ program: LiveProgram, series: Bool) async {
|
||||
guard let recorder = session.recorder else { return }
|
||||
do {
|
||||
if series {
|
||||
try await recorder.scheduleSeriesTimer(for: program)
|
||||
} else {
|
||||
try await recorder.scheduleTimer(for: program)
|
||||
}
|
||||
await load()
|
||||
} catch {
|
||||
tuneFailure = TuneFailure(
|
||||
channel: program.title,
|
||||
reason: ErrorText.describe(error),
|
||||
trace: []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was Live-TV gerade zeigt.
|
||||
enum LiveTVMode: String {
|
||||
case guide
|
||||
case channels
|
||||
case recordings
|
||||
}
|
||||
|
||||
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
|
||||
private struct ChannelRow: View {
|
||||
let channel: LiveChannel
|
||||
let programs: [LiveProgram]
|
||||
let isTuning: Bool
|
||||
let onTune: () -> Void
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
||||
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTune) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
||||
// fast nie quadratisch, und formatfüllend fehlten links und
|
||||
// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: HaloAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 56, height: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
Text(channel.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let current {
|
||||
Text(current.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(1)
|
||||
// Der Balken zeigt, wie weit die Sendung ist — beim
|
||||
// Umschalten die nützlichste Angabe überhaupt.
|
||||
WatchProgressBar(fraction: current.progress())
|
||||
.padding(.top, 2)
|
||||
} else {
|
||||
Text("liveTV.noProgram")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
if let next {
|
||||
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isTuning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Was beim Einschalten schiefging, in voller Länge.
|
||||
struct TuneFailure: Identifiable {
|
||||
let id = UUID()
|
||||
let channel: String
|
||||
let reason: String
|
||||
/// Jeder versuchte Weg mit der Antwort des Servers.
|
||||
let trace: [String]
|
||||
}
|
||||
|
||||
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
|
||||
///
|
||||
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
|
||||
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
|
||||
/// jede Runde davon kostet Tage.
|
||||
private struct TuneFailureSheet: View {
|
||||
let failure: TuneFailure
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text(failure.channel)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text(failure.reason)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
|
||||
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(HaloSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("liveTV.tuneFailed"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
||||
///
|
||||
/// Warum ein Raster und nicht eine Liste mit „läuft gerade": beim Fernsehen
|
||||
/// sucht man nicht einen Sender, sondern eine Sendung — und die findet man
|
||||
/// nur, wenn man sieht, was auf allen Sendern gleichzeitig läuft. Genau das
|
||||
/// ist die Achse.
|
||||
///
|
||||
/// Der Aufbau: die Senderspalte steht **außerhalb** des waagerechten
|
||||
/// Rollbereichs und bleibt deshalb stehen, während das Programm daran
|
||||
/// vorbeizieht. Die Zeitachse steht außerhalb des senkrechten und folgt dem
|
||||
/// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht
|
||||
/// gleichzeitig festgenagelt.
|
||||
struct ProgramGuide: View {
|
||||
let channels: [LiveChannel]
|
||||
let programs: [String: [LiveProgram]]
|
||||
let tuning: String?
|
||||
/// Die Adresse des Senderlogos.
|
||||
///
|
||||
/// Als Abschluss übergeben statt aus der Umgebung gelesen: so lässt sich
|
||||
/// das Raster außerhalb der App mit erfundenen Sendern zeichnen und
|
||||
/// ansehen. Die Senderspalte hat einmal gefehlt, und das war ohne einen
|
||||
/// Blick darauf nicht zu finden.
|
||||
let artwork: (LiveChannel) -> URL?
|
||||
let onTune: (LiveChannel, LiveProgram?) -> Void
|
||||
/// Eine Sendung vormerken — einmalig oder als ganze Reihe.
|
||||
///
|
||||
/// Beide optional: **nicht** jeder Server kann aufnehmen. `nil` heißt,
|
||||
/// dass die Knöpfe gar nicht erst erscheinen — besser als ein Knopf, der
|
||||
/// bei Plex nur eine Fehlermeldung auslöst.
|
||||
var onRecord: ((LiveProgram) -> Void)?
|
||||
var onRecordSeries: ((LiveProgram) -> Void)?
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var scrollX: CGFloat = 0
|
||||
@State private var selection: GuideSelection?
|
||||
@State private var now = Date()
|
||||
|
||||
private let channelWidth: CGFloat = 116
|
||||
private let rowHeight: CGFloat = 64
|
||||
/// Eine halbe Stunde ist gut 130 Punkte breit — schmal genug, dass ein
|
||||
/// Abend auf den Schirm passt, breit genug für einen lesbaren Titel.
|
||||
private let pointsPerMinute: CGFloat = 4.4
|
||||
private let axisHeight: CGFloat = 26
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
axis
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
|
||||
ScrollView(.vertical) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Ohne Vorrang verliert die Spalte den Platzstreit gegen
|
||||
// das Raster: dessen Breite ist die volle Zeitachse —
|
||||
// mehrere tausend Punkte —, und der HStack drückt den
|
||||
// Nachbarn dafür auf null. Genau so war die Senderspalte
|
||||
// verschwunden.
|
||||
channelColumn.layoutPriority(1)
|
||||
lanes
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.sheet(item: $selection) { selection in
|
||||
ProgramDetailSheet(
|
||||
channel: selection.channel,
|
||||
artworkURL: artwork(selection.channel),
|
||||
program: selection.program,
|
||||
isRunning: selection.program.isRunning(at: now),
|
||||
onTune: { onTune(selection.channel, selection.program) },
|
||||
onRecord: onRecord.map { record in { record(selection.program) } },
|
||||
onRecordSeries: onRecordSeries.map { record in { record(selection.program) } }
|
||||
)
|
||||
}
|
||||
// Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
|
||||
// paar Minuten nicht mehr. Einmal pro Minute reicht dafür.
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
now = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zeitfenster
|
||||
|
||||
/// Zeit in Punkte. Die Rechnungen dahinter stehen in `GuideLayout` und
|
||||
/// sind dort geprüft — hier wären sie unsichtbar falsch.
|
||||
private var layout: GuideLayout {
|
||||
GuideLayout(
|
||||
now: now,
|
||||
latestEnd: programs.values.flatMap { $0 }.map(\.endsAt).max(),
|
||||
pointsPerMinute: pointsPerMinute
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teile
|
||||
|
||||
private var axis: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("liveTV.channelCount \(channels.count)")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
||||
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
||||
// acht Punkte neben dem Programm darunter.
|
||||
.padding(.leading, HaloSpacing.snug)
|
||||
.frame(width: channelWidth, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
|
||||
// Der Streifen liegt als Auflage über einer durchsichtigen
|
||||
// Fläche, statt selbst im Stapel zu stehen: eine Auflage zählt
|
||||
// bei der Größenrechnung nicht mit. Stünde er selbst darin,
|
||||
// verlangte er die Breite der ganzen Zeitachse und schöbe die
|
||||
// Beschriftung links aus dem Bild.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .leading) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(layout.ticks, id: \.self) { tick in
|
||||
Text(tick.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.frame(width: layout.tickWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
.offset(x: -scrollX)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: axisHeight)
|
||||
.background(Color.haloCanvas)
|
||||
}
|
||||
|
||||
/// Was auf diesem Sender gerade läuft.
|
||||
private func running(on channel: LiveChannel) -> LiveProgram? {
|
||||
programs[channel.id]?.first { $0.isRunning(at: now) }
|
||||
}
|
||||
|
||||
private var channelColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
Button { onTune(channel, running(on: channel)) } label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 30, height: 30)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(channel.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if tuning == channel.id {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: channelWidth)
|
||||
.background(Color.haloElevated.opacity(0.4))
|
||||
}
|
||||
|
||||
private var lanes: some View {
|
||||
ScrollView(.horizontal) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
lane(for: channel)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Jetzt-Linie läuft mit dem Inhalt mit, nicht mit dem
|
||||
// Blickfeld — sie markiert einen Zeitpunkt, keine Stelle auf
|
||||
// dem Schirm.
|
||||
Rectangle()
|
||||
.fill(accent)
|
||||
.frame(width: 2)
|
||||
.offset(x: layout.x(for: now))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: layout.totalWidth, alignment: .topLeading)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.frame(height: CGFloat(channels.count) * rowHeight)
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.x
|
||||
} action: { _, offset in
|
||||
scrollX = offset
|
||||
}
|
||||
}
|
||||
|
||||
private func lane(for channel: LiveChannel) -> some View {
|
||||
let entries = programs[channel.id] ?? []
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
if entries.isEmpty {
|
||||
Text("liveTV.noProgram")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.frame(height: rowHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
ForEach(entries) { program in
|
||||
block(program, on: channel)
|
||||
.offset(x: layout.x(for: program.startsAt))
|
||||
}
|
||||
}
|
||||
.frame(width: layout.totalWidth, height: rowHeight, alignment: .topLeading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
}
|
||||
|
||||
/// Die Trennlinie gehört in die Zeile, nicht zwischen die Zeilen.
|
||||
///
|
||||
/// `Divider()` ist auf einem 3x-Schirm keinen ganzen Punkt hoch. Zwischen
|
||||
/// den Zeilen gestapelt summiert sich dieser Bruchteil, und Senderspalte
|
||||
/// und Programm stehen nach zwanzig Sendern sichtbar versetzt — die
|
||||
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
||||
private var rowSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color.haloSeparator)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private func block(_ program: LiveProgram, on channel: LiveChannel) -> some View {
|
||||
let running = program.isRunning(at: now)
|
||||
|
||||
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(program.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
||||
.background(
|
||||
running ? accent.opacity(0.28) : Color.haloElevated,
|
||||
in: .rect(cornerRadius: HaloRadius.control)
|
||||
)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
||||
// Umschalten die nützlichste Angabe überhaupt.
|
||||
if running {
|
||||
WatchProgressBar(fraction: program.progress(at: now))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die angetippte Sendung samt ihrem Sender.
|
||||
private struct GuideSelection: Identifiable {
|
||||
let channel: LiveChannel
|
||||
let program: LiveProgram
|
||||
|
||||
var id: String { "\(channel.id)-\(program.id)" }
|
||||
}
|
||||
|
||||
/// Was zu einer Sendung im Raster gehört, in voller Länge.
|
||||
private struct ProgramDetailSheet: View {
|
||||
let channel: LiveChannel
|
||||
let artworkURL: URL?
|
||||
let program: LiveProgram
|
||||
let isRunning: Bool
|
||||
let onTune: () -> Void
|
||||
let onRecord: (() -> Void)?
|
||||
let onRecordSeries: (() -> Void)?
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(channel.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(program.title)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
if let subtitle = program.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
Button { onTune(); dismiss() } label: {
|
||||
Label("liveTV.tune", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
|
||||
// Vormerken steht auch bei einer laufenden Sendung: der
|
||||
// Rest davon lässt sich noch aufnehmen. Bei einem Server
|
||||
// ohne Aufnahmen fehlt hier schlicht alles.
|
||||
if program.seriesTimerID != nil {
|
||||
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else if onRecord != nil || onRecordSeries != nil {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
if let onRecord {
|
||||
Button { onRecord(); dismiss() } label: {
|
||||
Label("liveTV.record", systemImage: "record.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let onRecordSeries {
|
||||
Button { onRecordSeries(); dismiss() } label: {
|
||||
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = program.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(HaloSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Aufnahmen und was noch vorgemerkt ist.
|
||||
///
|
||||
/// Der dritte Modus neben Raster und Senderliste — und der einzige, den es
|
||||
/// nicht überall gibt. Wer hierher kommt, hat einen Server, der aufnehmen
|
||||
/// kann; bei den anderen fehlt schon der Schalter dorthin.
|
||||
///
|
||||
/// Beides in **einer** Liste statt in zwei Ansichten: „was liegt da" und „was
|
||||
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
|
||||
/// stellt, stellt meist gleich die andere mit.
|
||||
struct RecordingsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
@State private var recordings: [LiveRecording] = []
|
||||
@State private var timers: [LiveTimer] = []
|
||||
@State private var seriesTimers: [LiveSeriesTimer] = []
|
||||
@State private var state: LoadState<Bool> = .loading
|
||||
/// Was gerade gelöscht werden soll — die Rückfrage hängt daran.
|
||||
@State private var pendingDeletion: LiveRecording?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
case .loaded where recordings.isEmpty && timers.isEmpty && seriesTimers.isEmpty:
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.recordings.empty.title", systemImage: "recordingtape")
|
||||
} description: {
|
||||
Text("liveTV.recordings.empty.description")
|
||||
}
|
||||
|
||||
case .loaded:
|
||||
list
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.task { await load() }
|
||||
.refreshable { await load() }
|
||||
.confirmationDialog(
|
||||
Text("liveTV.deleteRecording.confirm"),
|
||||
isPresented: Binding(
|
||||
get: { pendingDeletion != nil },
|
||||
set: { if !$0 { pendingDeletion = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.delete", role: .destructive) {
|
||||
if let target = pendingDeletion { Task { await delete(target) } }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) { pendingDeletion = nil }
|
||||
} message: {
|
||||
// Der Name gehört in die Rückfrage: „Wirklich löschen?" allein
|
||||
// beantwortet nicht, *was* — und eine Aufnahme ist weg, wenn sie
|
||||
// weg ist.
|
||||
if let pendingDeletion { Text(pendingDeletion.title) }
|
||||
}
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
if !recordings.isEmpty {
|
||||
Section {
|
||||
ForEach(recordings) { recording in
|
||||
// Über die Detailseite statt unmittelbar abspielen:
|
||||
// eine Aufnahme ist auf dem Server ein gewöhnlicher
|
||||
// Eintrag, und dort steht, in welchen Fassungen sie
|
||||
// vorliegt. Von hier aus zu starten hieße, das zu
|
||||
// raten.
|
||||
NavigationLink(value: MediaRoute(ratingKey: recording.id, title: recording.title)) {
|
||||
row(recording)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) { pendingDeletion = recording } label: {
|
||||
Label("action.delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
} header: {
|
||||
Text("liveTV.recordings")
|
||||
}
|
||||
}
|
||||
|
||||
if !timers.isEmpty || !seriesTimers.isEmpty {
|
||||
Section {
|
||||
ForEach(seriesTimers) { timer in
|
||||
seriesRow(timer)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
Task { await cancelSeries(timer) }
|
||||
} label: {
|
||||
Label("liveTV.cancelTimer", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(timers) { timer in
|
||||
timerRow(timer)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
Task { await cancel(timer) }
|
||||
} label: {
|
||||
Label("liveTV.cancelTimer", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
} header: {
|
||||
Text("liveTV.scheduled")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
// MARK: - Zeilen
|
||||
|
||||
private func row(_ recording: LiveRecording) -> some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: 16 / 9,
|
||||
fallbackSymbol: "recordingtape",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 64, height: 36)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(recording.title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
if recording.isInProgress {
|
||||
// Eine laufende Aufnahme ist keine fertige: sie wächst
|
||||
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
|
||||
// nicht das Ende ist.
|
||||
Label("liveTV.recording.inProgress", systemImage: "record.circle")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
if let subtitle = subtitle(for: recording) {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
|
||||
private func timerRow(_ timer: LiveTimer) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(timer.title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
Image(systemName: "rectangle.stack.badge.record")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(timer.name)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender und Beginn in einer Zeile — was davon vorhanden ist.
|
||||
private func subtitle(for recording: LiveRecording) -> String? {
|
||||
subtitle(channel: recording.channelName, start: recording.startsAt)
|
||||
}
|
||||
|
||||
private func subtitle(channel: String?, start: Date?) -> String? {
|
||||
var pieces: [String] = []
|
||||
if let channel { pieces.append(channel) }
|
||||
if let start { pieces.append(start.formatted(date: .abbreviated, time: .shortened)) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Laden und Ändern
|
||||
|
||||
private func load() async {
|
||||
guard let recorder = session.recorder else {
|
||||
state = .loaded(true)
|
||||
return
|
||||
}
|
||||
do {
|
||||
// Nebenläufig: drei Abfragen, die nichts voneinander wissen.
|
||||
// Nacheinander wäre die Wartezeit ihre Summe.
|
||||
async let fetchedRecordings = recorder.recordings()
|
||||
async let fetchedTimers = recorder.timers()
|
||||
async let fetchedSeries = recorder.seriesTimers()
|
||||
|
||||
recordings = try await fetchedRecordings
|
||||
timers = try await fetchedTimers
|
||||
seriesTimers = try await fetchedSeries
|
||||
state = .loaded(true)
|
||||
} catch {
|
||||
state = .failed(message: ErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func delete(_ recording: LiveRecording) async {
|
||||
pendingDeletion = nil
|
||||
guard let recorder = session.recorder else { return }
|
||||
try? await recorder.deleteRecording(id: recording.id)
|
||||
await load()
|
||||
}
|
||||
|
||||
private func cancel(_ timer: LiveTimer) async {
|
||||
guard let recorder = session.recorder else { return }
|
||||
try? await recorder.cancelTimer(id: timer.id)
|
||||
await load()
|
||||
}
|
||||
|
||||
private func cancelSeries(_ timer: LiveSeriesTimer) async {
|
||||
guard let recorder = session.recorder else { return }
|
||||
try? await recorder.cancelSeriesTimer(id: timer.id)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Der Ladezustand eines Bildschirms.
|
||||
///
|
||||
/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`,
|
||||
/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und
|
||||
/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden,
|
||||
/// was dann gilt.
|
||||
enum LoadState<Value: Sendable>: Sendable {
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(message: String)
|
||||
|
||||
var value: Value? {
|
||||
if case .loaded(let value) = self { return value }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension LoadState {
|
||||
/// Führt eine Ladeoperation aus und übersetzt Fehler in Text.
|
||||
///
|
||||
/// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei
|
||||
/// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch
|
||||
/// einen Ladekreis ersetzt zu werden.
|
||||
///
|
||||
/// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen
|
||||
/// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable`
|
||||
/// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt.
|
||||
@MainActor
|
||||
static func load(
|
||||
keepingPrevious previous: Self? = nil,
|
||||
_ operation: () async throws -> Value
|
||||
) async -> Self {
|
||||
do {
|
||||
return .loaded(try await operation())
|
||||
} catch {
|
||||
// Der Bildschirm wurde verlassen, während geladen wurde. Ein
|
||||
// Fehlertext wäre hier falsch — es ist niemand mehr da, den er
|
||||
// interessiert.
|
||||
//
|
||||
// Gefragt wird die Aufgabe, nicht der Fehler: eine abgebrochene
|
||||
// Netzanfrage kommt als Übertragungsfehler zurück und nicht als
|
||||
// `CancellationError`. Danach zu fangen sah richtig aus und griff
|
||||
// nie.
|
||||
if Task.isCancelled { return previous ?? .loading }
|
||||
return .failed(message: ErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
||||
///
|
||||
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie
|
||||
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
||||
/// Bildschirm eine andere Erklärung.
|
||||
enum ErrorText {
|
||||
static func describe(_ error: any Error) -> String {
|
||||
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
|
||||
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
|
||||
// seines Moduls dorthin.
|
||||
if let backendError = error as? MediaBackendError {
|
||||
return describe(backendError)
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
static func describe(_ error: MediaBackendError) -> String {
|
||||
switch error {
|
||||
case .unauthorized: String(localized: "error.unauthorized")
|
||||
case .notFound: String(localized: "error.notFound")
|
||||
case .noReachableConnection: String(localized: "error.serverUnreachable")
|
||||
case .server(let status): String(localized: "error.server \(status)")
|
||||
case .transport(let description): description
|
||||
case .decoding: String(localized: "error.unexpectedResponse")
|
||||
// Die Worte des Servers, nicht unsere: er weiß besser, warum er
|
||||
// ablehnt, als wir es raten könnten.
|
||||
case .refused(let reason): reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wohin ein Tipp auf eine Kachel führt.
|
||||
///
|
||||
/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der
|
||||
/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die
|
||||
/// Detailseite muss den Titel ohnehin frisch holen.
|
||||
struct MediaRoute: Hashable {
|
||||
let ratingKey: String
|
||||
/// Für die Titelzeile, solange noch geladen wird.
|
||||
let title: String
|
||||
}
|
||||
|
||||
/// Der Weg zu einer Mediathek.
|
||||
///
|
||||
/// Ein Wert statt einer Ansicht, und das ist kein Geschmack: in einem Stapel
|
||||
/// dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald
|
||||
/// eine Ansicht über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die
|
||||
/// **Wert**-Verknüpfungen darin ihr Ziel nicht mehr — jedes Antippen einer
|
||||
/// Kachel führte ins Leere, ohne Fehlermeldung.
|
||||
struct LibraryRoute: Hashable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
/// Der Weg zum Programmführer.
|
||||
struct LiveTVRoute: Hashable {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Der erste Buchstabe auf einer Scheibe.
|
||||
///
|
||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
||||
/// angemeldet ist.
|
||||
///
|
||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
||||
struct AccountAvatar: View {
|
||||
let name: String
|
||||
var diameter: CGFloat = 26
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
Text(name.prefix(1).uppercased())
|
||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
|
||||
// Fehler.
|
||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.background(accent, in: .circle)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Öffnet die Einstellungen.
|
||||
///
|
||||
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
|
||||
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
|
||||
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
|
||||
/// als Argument durchgereicht bekommen.
|
||||
@Entry var haloOpenSettings: () -> Void = {}
|
||||
}
|
||||
|
||||
/// Das Monogramm als Knopf für eine Werkzeugleiste.
|
||||
///
|
||||
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
|
||||
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
|
||||
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
|
||||
/// steht, kann nur der entscheiden, der die Reihe schreibt.
|
||||
struct AccountToolbarButton: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloOpenSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
Button(action: openSettings) {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("sidebar.settings"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Wer angemeldet ist — unten in der Seitenleiste.
|
||||
///
|
||||
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es in jeder
|
||||
/// App der letzte Eintrag ist. Und von dort führt der Weg in die
|
||||
/// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den
|
||||
/// Mediatheken stehen.
|
||||
///
|
||||
/// **Eine Karte, die über der Zeile aufgeht**, kein Systemmenü: mit denselben
|
||||
/// Abständen und derselben Schrift wie der Rest der App, statt der grauen
|
||||
/// Standardliste.
|
||||
///
|
||||
/// Sie hängt als Popover an der Zeile und wächst nicht in der Leiste selbst.
|
||||
/// Das war der erste Versuch, und er ging schief: die Leiste unten hat eine
|
||||
/// feste Höhe, die Karte wuchs nach unten aus dem Bild heraus, und die Zeile
|
||||
/// mit dem Namen verschwand dabei.
|
||||
///
|
||||
/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen
|
||||
/// ihren eigenen Eintrag.
|
||||
struct AccountBar: View {
|
||||
@Binding var selection: SidebarDestination
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var isExpanded = false
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
private var title: String {
|
||||
session.accountName ?? session.serverName
|
||||
}
|
||||
|
||||
/// Der Server steht nur dann darunter, wenn oben schon der Kontoname
|
||||
/// steht — sonst stünde zweimal dasselbe.
|
||||
private var subtitle: String? {
|
||||
session.accountName == nil ? nil : session.serverName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
accountRow
|
||||
.popover(isPresented: $isExpanded, arrowEdge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
action("account.settings", symbol: "gearshape") {
|
||||
isExpanded = false
|
||||
selection = .settings
|
||||
}
|
||||
separator
|
||||
action(
|
||||
"action.signOut",
|
||||
symbol: "rectangle.portrait.and.arrow.right",
|
||||
isDestructive: true
|
||||
) {
|
||||
isExpanded = false
|
||||
showsSignOutConfirmation = true
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 240)
|
||||
.background(Color.haloSurface)
|
||||
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
||||
// steht trotzdem da, damit aus dem Popover dort kein
|
||||
// bildschirmfüllendes Sheet wird.
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("settings.signOutConfirm"),
|
||||
isPresented: $showsSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.signOutExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
private var accountRow: some View {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
AccountAvatar(name: title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
// Ohne Vorrang staucht SwiftUI den Text vor dem Zwischenraum:
|
||||
// in einer schmalen Leiste stand vom Namen nur noch „S…" da.
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Zeigt nach oben: dorthin geht die Karte auf.
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(title))
|
||||
.accessibilityHint(Text("account.hint"))
|
||||
}
|
||||
|
||||
private func action(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
isDestructive: Bool = false,
|
||||
perform: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: perform) {
|
||||
Label(title, systemImage: symbol)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Wo die Navigation sitzt.
|
||||
///
|
||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine
|
||||
/// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt.
|
||||
enum NavigationPlacement: String {
|
||||
case sidebar
|
||||
case top
|
||||
}
|
||||
|
||||
/// Ein Eintrag in der Navigation.
|
||||
struct NavigationEntry: Identifiable, Arrangeable {
|
||||
let destination: SidebarDestination
|
||||
let title: Text
|
||||
let symbol: String
|
||||
/// Der Titel als reiner Text — für die Sortierlogik und für die
|
||||
/// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen.
|
||||
let plainTitle: String
|
||||
|
||||
var id: SidebarDestination { destination }
|
||||
var arrangementID: String { destination.storageKey }
|
||||
var arrangementTitle: String { plainTitle }
|
||||
}
|
||||
|
||||
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
|
||||
struct NavigationGroup: Identifiable {
|
||||
let id: String
|
||||
let title: LocalizedStringKey?
|
||||
let entries: [NavigationEntry]
|
||||
}
|
||||
|
||||
// MARK: - Seitenleiste
|
||||
|
||||
/// Die Seitenleiste, wie sie auf dem iPad aussieht — überall.
|
||||
///
|
||||
/// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den
|
||||
/// Fensterrand setzt und sich das nicht umstellen lässt. Siehe
|
||||
/// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das
|
||||
/// Kleid ist unseres.
|
||||
///
|
||||
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
|
||||
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
|
||||
///
|
||||
/// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich.
|
||||
/// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche
|
||||
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
||||
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
||||
/// „umrandet" und „aus Glas".
|
||||
struct HaloSidebar: View {
|
||||
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
||||
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@Binding var placement: NavigationPlacement
|
||||
let layout: NavigationLayoutSettings
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
||||
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
||||
@State private var collapsed: Set<String> = []
|
||||
@State private var isEditing = false
|
||||
|
||||
/// Die Fläche, auf der die Leiste liegt.
|
||||
///
|
||||
/// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner
|
||||
/// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein
|
||||
/// heller Balken von mehreren Punkten Breite, der neben der Leiste stand —
|
||||
/// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts
|
||||
/// zu erkennen.
|
||||
///
|
||||
/// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche
|
||||
/// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen
|
||||
/// wird; und ein Haarstrich, der oben links aufhellt und unten rechts
|
||||
/// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was
|
||||
/// schwebt, wirft einen.
|
||||
@ViewBuilder
|
||||
static func surface(in shape: some InsettableShape) -> some View {
|
||||
shape
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
|
||||
.overlay {
|
||||
shape.strokeBorder(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .white.opacity(0.30), location: 0),
|
||||
.init(color: .white.opacity(0.05), location: 0.5),
|
||||
.init(color: .white.opacity(0.16), location: 1),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static let width: CGFloat = 235
|
||||
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
|
||||
static let margin: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
|
||||
// **Doch mit Bildlaufansicht.** Hier stand „die Einträge sind
|
||||
// wenige und passen" — und das stimmte auf einem iPad und im
|
||||
// Hochformat. Auf einem grossen iPhone **quer** ist der
|
||||
// Bildschirm vierhundertvierzig Punkte hoch, und die Leiste
|
||||
// braucht mit Kopf, Gruppen, Trenner und Kontozeile gut
|
||||
// fünfhundertdreissig.
|
||||
//
|
||||
// Eine `VStack` schrumpft dann nicht, sie **überragt**: der
|
||||
// `HStack` daneben nimmt das Maximum, und der ganze Wurzelbaum
|
||||
// wurde hundertachtunddreissig Punkte höher als der Bildschirm.
|
||||
// Was darin als Überlagerung liegt — der Player — ging oben und
|
||||
// unten hinaus, und Kopfzeile wie Zeitangaben waren weg. Der
|
||||
// Fehler sass also in der Navigation und zeigte sich im Player.
|
||||
//
|
||||
// `basedOnSize`: es wird nur gescrollt, wenn es nötig ist. Wo die
|
||||
// Einträge passen, sieht man keinen Unterschied.
|
||||
//
|
||||
// Nachgerechnet, damit es nicht wiederkommt: scrollbarer Teil
|
||||
// 421 Punkte (acht Zeilen à 36,33, eine Gruppenüberschrift
|
||||
// 22,33, acht Zwischenräume à 12, unten 12), starrer Rest
|
||||
// 137,6 (Kopfzeile 48,33, Trenner 1, Kontozeile 64,27, aussen
|
||||
// zweimal 12). Zusammen 558,6 — gemessen wurden 558,67. Nach dem
|
||||
// Umbau bleiben 137,6 starr, bei 420 verfügbaren: auch mit
|
||||
// doppelt so vielen Mediatheken passt das.
|
||||
//
|
||||
// Beim Bearbeiten braucht es eine Liste — nur die bringt das
|
||||
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
||||
if isEditing {
|
||||
Text("sidebar.editHint")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.bottom, HaloSpacing.snug)
|
||||
|
||||
List {
|
||||
ForEach(groups) { group in
|
||||
Section {
|
||||
ForEach(editable(group)) { entry in
|
||||
editRow(entry)
|
||||
}
|
||||
.onMove { move(in: group, from: $0, to: $1) }
|
||||
} header: {
|
||||
if let title = group.title {
|
||||
Text(title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
ForEach(groups) { group in
|
||||
section(visible(group))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.bottom, HaloSpacing.regular)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
AccountBar(selection: $selection)
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.frame(width: Self.width)
|
||||
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
||||
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
||||
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
||||
// und Leiste. Der schwebende Eindruck kommt von den übrigen drei
|
||||
// Seiten und vom Schatten.
|
||||
.padding(.leading, Self.margin)
|
||||
.padding(.vertical, Self.margin)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
||||
} label: {
|
||||
Text(isEditing ? "action.done" : "sidebar.edit")
|
||||
.font(.haloCaption)
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.tight)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if !isEditing {
|
||||
Button {
|
||||
placement = .top
|
||||
} label: {
|
||||
Image(systemName: "rectangle.topthird.inset.filled")
|
||||
.font(.haloRowHeader)
|
||||
.padding(HaloSpacing.tight)
|
||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
||||
// wie das Zeichen selbst — auf dem Mac trifft man das
|
||||
// mit dem Zeiger kaum.
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToTop"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.top, HaloSpacing.regular)
|
||||
.padding(.bottom, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
||||
/// Reihenfolge, auch die ausgeblendeten.
|
||||
private func editable(_ group: NavigationGroup) -> [NavigationEntry] {
|
||||
layout.ordered(group.entries)
|
||||
}
|
||||
|
||||
/// Verschiebt einen Eintrag innerhalb seiner Gruppe.
|
||||
///
|
||||
/// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander.
|
||||
/// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine
|
||||
/// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe
|
||||
/// wieder eine Sonderregel zu haben.
|
||||
private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) {
|
||||
var entries = editable(group)
|
||||
entries.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var order: [String] = []
|
||||
for other in groups {
|
||||
let ids = other.id == group.id
|
||||
? entries.map(\.arrangementID)
|
||||
: editable(other).map(\.arrangementID)
|
||||
order.append(contentsOf: ids)
|
||||
}
|
||||
layout.setOrder(order)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(_ group: NavigationGroup) -> some View {
|
||||
if let title = group.title {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.25)) {
|
||||
if collapsed.contains(group.id) {
|
||||
collapsed.remove(group.id)
|
||||
} else {
|
||||
collapsed.insert(group.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if !collapsed.contains(group.id) {
|
||||
ForEach(group.entries) { entry in
|
||||
row(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus,
|
||||
/// Reihenfolge angewandt.
|
||||
private func visible(_ group: NavigationGroup) -> NavigationGroup {
|
||||
NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries))
|
||||
}
|
||||
|
||||
private func editRow(_ entry: NavigationEntry) -> some View {
|
||||
let isHidden = layout.isHidden(entry.arrangementID)
|
||||
|
||||
return HStack(spacing: HaloSpacing.snug) {
|
||||
Button {
|
||||
layout.setHidden(!isHidden, for: entry.arrangementID)
|
||||
} label: {
|
||||
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
||||
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide"))
|
||||
|
||||
Image(systemName: entry.symbol)
|
||||
.frame(width: 22)
|
||||
entry.title
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
|
||||
}
|
||||
|
||||
private func row(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
Image(systemName: entry.symbol)
|
||||
.font(.haloBody)
|
||||
.frame(width: 22, alignment: .center)
|
||||
entry.title
|
||||
.font(.haloBody)
|
||||
// Ein langer Mediathekenname darf die Leiste nicht
|
||||
// breiter machen; sie hat eine feste Breite.
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if isSelected {
|
||||
Capsule().fill(Color.haloElevated)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leiste oben
|
||||
|
||||
/// Dieselben Einträge als schwebende Leiste oben.
|
||||
///
|
||||
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
||||
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
||||
/// hinauswächst, wäre schlechter als ein Menü.
|
||||
struct HaloTopBar: View {
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@Binding var placement: NavigationPlacement
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
Button {
|
||||
placement = .sidebar
|
||||
} label: {
|
||||
Image(systemName: "sidebar.leading")
|
||||
.font(.haloBody)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
||||
// das Zeichen; auf dem Mac war der Umschalter damit
|
||||
// praktisch nicht zu treffen.
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToSide"))
|
||||
|
||||
ForEach(groups) { group in
|
||||
if group.title == nil {
|
||||
ForEach(group.entries) { entry in
|
||||
pill(entry)
|
||||
}
|
||||
} else {
|
||||
menu(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background { HaloSidebar.surface(in: .capsule) }
|
||||
.clipShape(.capsule)
|
||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
private func pill(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
entry.title
|
||||
.font(.haloBody)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if isSelected { Capsule().fill(Color.haloElevated) }
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func menu(_ group: NavigationGroup) -> some View {
|
||||
let containsSelection = group.entries.contains { $0.destination == selection }
|
||||
|
||||
return Menu {
|
||||
ForEach(group.entries) { entry in
|
||||
Button { selection = entry.destination } label: {
|
||||
Label { entry.title } icon: { Image(systemName: entry.symbol) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
if let title = group.title {
|
||||
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
||||
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
||||
// Grund, an einem Wort zu sparen.
|
||||
Text(title).fixedSize()
|
||||
}
|
||||
Image(systemName: "chevron.down").font(.haloCaption)
|
||||
}
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if containsSelection { Capsule().fill(Color.haloElevated) }
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import MediaKit
|
||||
|
||||
/// Ein Navigationsstapel mit allen Zielen — und einem Weg, von innen zu
|
||||
/// schieben.
|
||||
///
|
||||
/// **Warum der Pfad hier liegt und nicht beim System.** Ein `NavigationLink`
|
||||
/// schiebt selbst; wer stattdessen aus einem Knopf heraus schieben will,
|
||||
/// braucht eine Bindung. Genau das verlangt die Kachel, seit Antippen
|
||||
/// abspielt: der Weg auf die Detailseite führt dann über das Kontextmenü, und
|
||||
/// ein Menüeintrag ist kein Link.
|
||||
///
|
||||
/// **Die Ziele stehen hier und nur hier.** Sie je Bildschirm anzumelden hat
|
||||
/// schon einmal nicht funktioniert: liegen zwei Bildschirme übereinander,
|
||||
/// entscheidet sonst die Reihenfolge, und ein Tippen landete im Nichts.
|
||||
struct MediaStack<Content: View>: View {
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
content()
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
.navigationDestination(for: LibraryRoute.self) { route in
|
||||
if let library = session.libraries.first(where: { $0.id == route.id }) {
|
||||
LibraryView(library: library)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
||||
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
||||
}
|
||||
.environment(\.haloPush) { ziel in path.append(ziel) }
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Schiebt ein Ziel auf den Stapel, in dem man gerade steht.
|
||||
///
|
||||
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
|
||||
/// in einer Reihe, die in der Startseite steckt, die im Stapel steckt.
|
||||
/// Eine Bindung durch jede Ebene zu reichen hiesse, drei Ansichten um ein
|
||||
/// Argument zu erweitern, das sie nur weitergeben.
|
||||
///
|
||||
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
||||
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
||||
@Entry var haloPush: (any Hashable) -> Void = { _ in }
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
import HaloDownloads
|
||||
import HaloPlayback
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
///
|
||||
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
|
||||
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
||||
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
||||
struct RootView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(HaloTheme.self) private var theme
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
|
||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||
///
|
||||
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
|
||||
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
|
||||
/// genau das ist vorher passiert.
|
||||
@State private var player: PlayerModel?
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@State private var selection: SidebarDestination = .home
|
||||
/// Was der Nutzer an der Navigation umgestellt hat.
|
||||
///
|
||||
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
||||
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
||||
/// zum Aufbewahren.
|
||||
@AppStorage("halo.navigation.customization")
|
||||
private var customization = TabViewCustomization()
|
||||
|
||||
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
||||
///
|
||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
||||
@AppStorage("halo.navigation.placement")
|
||||
private var storedPlacement: NavigationPlacement = .sidebar
|
||||
|
||||
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
||||
///
|
||||
/// Der Umweg ist nötig, damit das Umschalten überhaupt zu sehen ist:
|
||||
/// `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung
|
||||
/// erst danach zurück. Sie kommt also außerhalb der Animation an, und die
|
||||
/// Leiste sprang von einer Stellung in die andere, statt zu wandern.
|
||||
///
|
||||
/// Hier ändert sich der Zustand sofort und im selben Zug; gespeichert wird
|
||||
/// hinterher.
|
||||
@State private var placement: NavigationPlacement = .sidebar
|
||||
|
||||
/// Wie die Leiste umzieht. Kurz, aber sichtbar.
|
||||
private static let placementAnimation: Animation = .snappy(duration: 0.32)
|
||||
|
||||
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
|
||||
/// überhaupt dastehen soll.
|
||||
@State private var navigationLayout = NavigationLayoutSettings()
|
||||
|
||||
/// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist.
|
||||
@State private var topChrome = TopChrome()
|
||||
|
||||
/// Wie hoch die Statusleiste baut.
|
||||
///
|
||||
/// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte,
|
||||
/// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des
|
||||
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
|
||||
/// wäre er längst null.
|
||||
@State private var statusBarHeight: CGFloat = 0
|
||||
|
||||
/// Ob die Einstellungen gerade offen sind.
|
||||
///
|
||||
/// Nur auf dem iPhone: dort gibt es den Tab nicht mehr, und der Weg führt
|
||||
/// über das Monogramm oben rechts.
|
||||
@State private var showsSettings = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch session.phase {
|
||||
case .launching:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.haloCanvas)
|
||||
|
||||
case .signedOut:
|
||||
SignInView()
|
||||
|
||||
case .connecting(let name):
|
||||
ConnectingView(serverName: name)
|
||||
|
||||
case .failed(let message):
|
||||
ConnectionFailureView(
|
||||
message: message,
|
||||
onRetry: { Task { await session.retry() } },
|
||||
onSignOut: { session.signOut() }
|
||||
)
|
||||
|
||||
case .ready:
|
||||
library
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.environment(topChrome)
|
||||
// **Aus dem Fenster, nicht aus dem Layout.**
|
||||
//
|
||||
// Hier stand `proxy.safeAreaInsets.top` — ein Wert, der beim Drehen
|
||||
// erst eine Runde später stimmt. Nachgemessen sah man das genau
|
||||
// einmal: quer war die Seitenleiste zuerst 620,67 Punkte hoch und
|
||||
// dann 558,67. Die Differenz sind zweiundsechzig, und die kamen von
|
||||
// `.padding(.top, statusBarHeight)` an der Leiste weiter unten, das
|
||||
// noch den Wert aus dem Hochformat trug.
|
||||
//
|
||||
// Das Fenster kennt seinen Rand ohne Umweg über das Layout, das er
|
||||
// selbst beeinflusst. Die Grösse bleibt der Anlass zum Nachfragen,
|
||||
// der **Wert** kommt von aussen — und stimmt damit im ersten
|
||||
// Durchgang.
|
||||
//
|
||||
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
||||
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile
|
||||
// spart eine Layoutrunde, mehr nicht.
|
||||
.onGeometryChange(for: CGSize.self) { proxy in
|
||||
proxy.size
|
||||
} action: { _ in statusBarHeight = ScreenSafeArea.current.top }
|
||||
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
||||
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||
.haloAccent(theme)
|
||||
.tint(theme.accentColor)
|
||||
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
||||
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
||||
.alert(
|
||||
Text("quickPlay.failed"),
|
||||
isPresented: Binding(
|
||||
get: { quickPlay.failure != nil },
|
||||
set: { if !$0 { quickPlay.failure = nil } }
|
||||
)
|
||||
) {
|
||||
Button("action.close", role: .cancel) { quickPlay.failure = nil }
|
||||
} message: {
|
||||
if let grund = quickPlay.failure { Text(grund) }
|
||||
}
|
||||
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
||||
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
||||
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
||||
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
|
||||
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
|
||||
// ist, und der Übergang ist ruhig.
|
||||
.overlay {
|
||||
if playback.presentation == .full, let player {
|
||||
// **Bildfüllend, und der Player passt drinnen selbst auf.**
|
||||
//
|
||||
// Ohne diese Zeile verliert die Bildfläche ihr
|
||||
// `ignoresSafeArea` mit — das Bild wäre im Querformat um die
|
||||
// Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der
|
||||
// Player deshalb aus dem Fenster; siehe `ScreenSafeArea`.
|
||||
PlayerView(model: player)
|
||||
.ignoresSafeArea()
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.animation(.snappy(duration: 0.3), value: playback.presentation)
|
||||
.onChange(of: playback.request) { _, request in
|
||||
Task { await handOver(to: request) }
|
||||
}
|
||||
.task { await session.restore() }
|
||||
// **Kommt das Netz zurück, geht es weiter.**
|
||||
//
|
||||
// Ein Download, den ein Funkloch unterbrochen hat, lag bisher da, bis
|
||||
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
||||
// Stunde wieder stand.
|
||||
//
|
||||
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht
|
||||
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
||||
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
||||
.onChange(of: network.isReachable) { _, erreichbar in
|
||||
guard erreichbar else { return }
|
||||
Task { await downloads.resumeInterrupted() }
|
||||
}
|
||||
.onChange(of: session.phase) { _, newPhase in
|
||||
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
||||
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
||||
if newPhase == .ready, case .library = selection { selection = .home }
|
||||
}
|
||||
.onChange(of: sizeClass) { _, _ in
|
||||
// Beim Drehen oder Teilen des Fensters wechselt die Form der
|
||||
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
|
||||
// Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst
|
||||
// einen leeren Bereich.
|
||||
switch selection {
|
||||
// `.settings` gehört dazu, seit es den Tab auf dem iPhone nicht
|
||||
// mehr gibt: wer am iPad in den Einstellungen steht und das
|
||||
// Fenster schmal zieht, stünde sonst vor einem Bereich, den die
|
||||
// Leiste gar nicht führt.
|
||||
case .library, .libraryHub, .liveTV, .settings: selection = .home
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wechselt den laufenden Titel.
|
||||
///
|
||||
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
|
||||
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
|
||||
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
|
||||
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
|
||||
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
|
||||
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
||||
/// abgestimmt, bevor es hier ankommt.
|
||||
private func handOver(to request: PlaybackRequest?) async {
|
||||
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
|
||||
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
|
||||
// weiter, während der neue startet, streiten sich zwei um dieselbe
|
||||
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
|
||||
// als neue Wiedergabe, die stumm blieb.
|
||||
let previous = player
|
||||
previous?.silence()
|
||||
player = nil
|
||||
if let previous { Task { await previous.releaseOnServer() } }
|
||||
|
||||
guard let request else { return }
|
||||
|
||||
let fresh = PlayerModel(
|
||||
request: request,
|
||||
backend: session.backend,
|
||||
liveTV: session.liveTV,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
languages: languages,
|
||||
subtitles: subtitleAppearance,
|
||||
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
|
||||
// über einen Reverse Proxy geht, erreicht seinen Server auch von
|
||||
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
|
||||
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
|
||||
// zuhause am Kabel ohne Not herunterrechnen.
|
||||
isLocalConnection: session.isLocalConnection,
|
||||
connectionDescription: session.connectionDescription,
|
||||
unavailableMessage: session.playbackUnavailableMessage
|
||||
)
|
||||
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
||||
// der Kachel — dort kam man vorher gar nicht mehr heraus.
|
||||
fresh.onPictureInPictureRestore { playback.expand() }
|
||||
|
||||
player = fresh
|
||||
await fresh.start()
|
||||
}
|
||||
|
||||
/// Die Navigation.
|
||||
///
|
||||
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
|
||||
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
|
||||
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
|
||||
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
|
||||
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
|
||||
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
|
||||
/// Jahr vorgibt.
|
||||
///
|
||||
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
|
||||
/// ausfahrbares Sheet, eine geteilte Ansicht und eine Reihe oben. Sie
|
||||
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
|
||||
/// gepflegt werden.
|
||||
/// Läuft diese App gerade auf einem Mac?
|
||||
///
|
||||
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
|
||||
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
|
||||
/// Unterschied sichtbar.
|
||||
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
|
||||
|
||||
private var tabs: some View {
|
||||
TabView(selection: $selection) {
|
||||
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
||||
// **Innerhalb des Stapels, nicht darum herum.** Als Überzug
|
||||
// über `mediaStack` lag er auch über jedem **geschobenen**
|
||||
// Bildschirm — auf einer Detailseite also über deren
|
||||
// Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er
|
||||
// für die Startseite und endet mit ihr.
|
||||
//
|
||||
// Dort ist ein Überzug der einzige Platz: die Startseite
|
||||
// schaltet ihre Navigationsleiste ab, ein `ToolbarItem`
|
||||
// erschiene gar nicht.
|
||||
mediaStack {
|
||||
HomeView()
|
||||
.overlay(alignment: .topTrailing) { profileButton }
|
||||
}
|
||||
}
|
||||
.customizationID("halo.tab.home")
|
||||
|
||||
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
||||
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
||||
// die Leiste unten hat fünf Plätze, und alles darüber schiebt das
|
||||
// System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei
|
||||
// Navigationsleisten übereinander tragen, eine vom System und eine
|
||||
// von uns.
|
||||
if sizeClass == .regular {
|
||||
TabSection {
|
||||
ForEach(session.libraries) { library in
|
||||
Tab(
|
||||
value: SidebarDestination.library(key: library.id, title: library.title)
|
||||
) {
|
||||
mediaStack { LibraryView(library: library) }
|
||||
} label: {
|
||||
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
||||
}
|
||||
.customizationID("halo.tab.library.\(library.id)")
|
||||
}
|
||||
} header: {
|
||||
Text("sidebar.libraries")
|
||||
}
|
||||
.customizationID("halo.tab.libraries")
|
||||
|
||||
if session.supportsLiveTV {
|
||||
Tab(value: SidebarDestination.liveTV) {
|
||||
mediaStack { LiveTVView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.customizationID("halo.tab.liveTV")
|
||||
}
|
||||
} else {
|
||||
Tab(value: SidebarDestination.libraryHub) {
|
||||
mediaStack { LibraryBrowser() }
|
||||
} label: {
|
||||
tabLabel("sidebar.library", symbol: "books.vertical")
|
||||
}
|
||||
.customizationID("halo.tab.libraryHub")
|
||||
}
|
||||
|
||||
Tab(value: SidebarDestination.downloads) {
|
||||
mediaStack { DownloadsView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
||||
}
|
||||
.customizationID("halo.tab.downloads")
|
||||
|
||||
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
||||
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
||||
// sind nichts, was man im Wechsel mit der Mediathek benutzt; ein
|
||||
// Platz in der Leiste unten unterstellt genau das. In der
|
||||
// Seitenleiste bleibt der Eintrag, dort ist Platz.
|
||||
if sizeClass == .regular {
|
||||
Tab(value: SidebarDestination.settings) {
|
||||
mediaStack { SettingsView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.settings", symbol: "gearshape")
|
||||
}
|
||||
.customizationID("halo.tab.settings")
|
||||
}
|
||||
|
||||
// Die Suche nur dort, wo Platz für sie ist.
|
||||
//
|
||||
// Die Leiste unten hat fünf Plätze. Mit der Suche wäre einer davon
|
||||
// für etwas belegt, das man in einer Mediathek sucht und nicht in
|
||||
// einer Leiste — deshalb sitzt sie auf dem iPhone über der
|
||||
// Mediathek, neben Filter und Ansicht.
|
||||
if sizeClass == .regular {
|
||||
Tab(value: SidebarDestination.search, role: .search) {
|
||||
mediaStack { SearchView() }
|
||||
}
|
||||
.customizationID("halo.tab.search")
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tabViewCustomization($customization)
|
||||
}
|
||||
|
||||
/// Die Navigation, wie **wir** sie zeichnen.
|
||||
///
|
||||
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
|
||||
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
|
||||
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
|
||||
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
|
||||
///
|
||||
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
|
||||
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
|
||||
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
|
||||
/// zeichnen wir es selbst.
|
||||
///
|
||||
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
|
||||
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
|
||||
private var library: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if sizeClass == .regular {
|
||||
HStack(spacing: 0) {
|
||||
if placement == .sidebar {
|
||||
HaloSidebar(
|
||||
groups: navigationGroups,
|
||||
selection: $selection,
|
||||
placement: $placement.animation(Self.placementAnimation),
|
||||
layout: navigationLayout
|
||||
)
|
||||
// Sie klappt nach **oben** weg und kommt von oben
|
||||
// zurück — und die Leiste oben schiebt sich seitwärts
|
||||
// herein beziehungsweise hinaus. Zusammen sieht es aus,
|
||||
// als zöge dieselbe Leiste um: erst zusammenklappen,
|
||||
// dann hinüberschieben.
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
// Die Statusleiste gehört ihr nicht. Seit der Baum den
|
||||
// Sicherheitsrand ignoriert, säße ihre Oberkante sonst
|
||||
// unter der Uhrzeit.
|
||||
.padding(.top, statusBarHeight)
|
||||
}
|
||||
|
||||
tabs
|
||||
// Der Platz oben als **Sicherheitsrand**, nicht als
|
||||
// Abstand.
|
||||
//
|
||||
// Der Unterschied ist der schwarze Balken, über den du
|
||||
// dich zu Recht gewundert hast: mit einem Abstand hört
|
||||
// der Inhalt dort auf und die Fläche darüber bleibt für
|
||||
// immer leer. Als Sicherheitsrand fängt er im
|
||||
// Ruhezustand ebenso darunter an — aber beim Scrollen
|
||||
// wandert er **hindurch**, bis an den Bildschirmrand.
|
||||
// Die Leiste ist eine Insel; hinter einer Insel läuft
|
||||
// das Wasser durch.
|
||||
//
|
||||
// Anders als `safeAreaPadding` erreicht das auch die
|
||||
// geschobenen Bildschirme: deren Navigationsleisten
|
||||
// richten sich danach.
|
||||
//
|
||||
// Die Statusleiste ist mit eingerechnet, weil der Baum
|
||||
// sie gleich unten ignoriert. Ohne sie begänne der
|
||||
// Inhalt hinter der Uhrzeit.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Color.clear.frame(height: topInset)
|
||||
}
|
||||
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
||||
.overlay(alignment: .top) {
|
||||
if placement == .top {
|
||||
HaloTopBar(
|
||||
groups: navigationGroups.map(visible),
|
||||
selection: $selection,
|
||||
placement: $placement.animation(Self.placementAnimation)
|
||||
)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
.padding(.top, statusBarHeight)
|
||||
// Beim Scrollen fährt sie nach oben weg.
|
||||
//
|
||||
// Nur sie: der Sicherheitsrand darunter bleibt,
|
||||
// wie er ist. Schrumpfte er mit, rückte der
|
||||
// Inhalt bei jedem Ein- und Ausblenden nach —
|
||||
// man scrollt, und die Seite ruckt einem unter
|
||||
// dem Finger weg.
|
||||
.offset(y: topChrome.isRevealed ? 0 : -topInset)
|
||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hier reißt der Baum bis an den Bildschirmrand auf.
|
||||
//
|
||||
// Das ist der eigentliche Griff. Vorher saß alles — Leiste,
|
||||
// Bildschirme, Bildlaufansichten — innerhalb des
|
||||
// Sicherheitsrands, und deshalb konnte kein noch so gut
|
||||
// gesetzter Rand weiter unten den schwarzen Streifen über der
|
||||
// Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er.
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
} else {
|
||||
tabs
|
||||
}
|
||||
#else
|
||||
tabs
|
||||
#endif
|
||||
}
|
||||
// **Als Blatt, nicht als Bereich.** `SettingsView` ist ein Formular
|
||||
// aus geschobenen Unterbildschirmen und braucht nur einen
|
||||
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
||||
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
||||
// statt in einem Bereich, den man erst wieder verlassen muss.
|
||||
.environment(\.haloOpenSettings, { showsSettings = true })
|
||||
.sheet(isPresented: $showsSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
// Ein Blatt lässt sich nach unten wegziehen — aber nicht
|
||||
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
||||
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button("action.close") { showsSettings = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
.onAppear { placement = storedPlacement }
|
||||
.onChange(of: placement) { _, new in
|
||||
storedPlacement = new
|
||||
// Zieht die Navigation um, soll die neue Leiste nicht
|
||||
// ausgeblendet ankommen.
|
||||
topChrome.isRevealed = true
|
||||
}
|
||||
// Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss.
|
||||
.onChange(of: topInset, initial: true) { _, new in topChrome.inset = new }
|
||||
}
|
||||
|
||||
/// Der Weg in die Einstellungen auf dem iPhone.
|
||||
///
|
||||
/// **Ein Überzug, kein Werkzeugleisten-Knopf.** Die Startseite schaltet
|
||||
/// ihre Navigationsleiste ganz ab — sie war leer und belegte trotzdem
|
||||
/// Höhe —, ein `ToolbarItem` erschiene dort also gar nicht. Hier liegt er
|
||||
/// einmal über allen Bildschirmen statt dreimal in deren Leisten, und er
|
||||
/// sitzt überall an derselben Stelle.
|
||||
///
|
||||
/// Er blendet sich beim Scrollen aus wie die Insel auf dem iPad: über
|
||||
/// einem Film, den man gerade sucht, hat ein Kontoknopf nichts verloren.
|
||||
@ViewBuilder
|
||||
private var profileButton: some View {
|
||||
Button {
|
||||
showsSettings = true
|
||||
} label: {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
.padding(HaloSpacing.snug)
|
||||
// Damit er auch über einem hellen Aufmacher steht.
|
||||
.background(.black.opacity(0.28), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, HaloSpacing.regular)
|
||||
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
||||
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
||||
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||
.allowsHitTesting(topChrome.isRevealed)
|
||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||
.accessibilityLabel(Text("sidebar.settings"))
|
||||
}
|
||||
|
||||
/// Wie viel Platz die Leiste oben braucht.
|
||||
private static let topBarHeight: CGFloat = 52
|
||||
|
||||
/// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt.
|
||||
///
|
||||
/// Die Statusleiste plus das, was die Navigation dort belegt — bei der
|
||||
/// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe.
|
||||
///
|
||||
/// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt
|
||||
/// schwebt nichts, worunter man greifen müsste.
|
||||
private var topInset: CGFloat {
|
||||
guard sizeClass == .regular else { return 0 }
|
||||
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight)
|
||||
}
|
||||
|
||||
private var navigationGroups: [NavigationGroup] {
|
||||
var groups: [NavigationGroup] = [
|
||||
NavigationGroup(id: "main", title: nil, entries: [
|
||||
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house", plainTitle: String(localized: "sidebar.home")),
|
||||
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle", plainTitle: String(localized: "sidebar.downloads")),
|
||||
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape", plainTitle: String(localized: "sidebar.settings")),
|
||||
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass", plainTitle: String(localized: "sidebar.search")),
|
||||
])
|
||||
]
|
||||
|
||||
var media = session.libraries.map { library in
|
||||
NavigationEntry(
|
||||
destination: .library(key: library.id, title: library.title),
|
||||
title: Text(library.title),
|
||||
symbol: library.kind.symbol,
|
||||
plainTitle: library.title
|
||||
)
|
||||
}
|
||||
if session.supportsLiveTV {
|
||||
media.append(NavigationEntry(
|
||||
destination: .liveTV,
|
||||
title: Text("sidebar.liveTV"),
|
||||
symbol: "dot.radiowaves.left.and.right",
|
||||
plainTitle: String(localized: "sidebar.liveTV")
|
||||
))
|
||||
}
|
||||
if !media.isEmpty {
|
||||
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/// Eine Gruppe so, wie sie dastehen soll — ausgeblendete Einträge raus,
|
||||
/// Reihenfolge angewandt. Die Seitenleiste macht das selbst, weil sie zum
|
||||
/// Bearbeiten auch die ausgeblendeten braucht.
|
||||
private func visible(_ group: NavigationGroup) -> NavigationGroup {
|
||||
NavigationGroup(
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
entries: navigationLayout.apply(to: group.entries)
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
|
||||
///
|
||||
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
|
||||
/// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon
|
||||
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
|
||||
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
|
||||
/// nicht; es passiert einfach nichts.
|
||||
private func mediaStack<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
|
||||
MediaStack(content: content)
|
||||
}
|
||||
|
||||
/// Die Breite der Seitenleiste auf dem Mac.
|
||||
///
|
||||
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
|
||||
/// kurzen Wörtern wie „Start" so weit zusammen, dass der Kontoname darunter
|
||||
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
|
||||
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
|
||||
/// was eine Seitenleiste auf dem iPad breit ist.
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
|
||||
(min: 190, ideal: 230, max: 320)
|
||||
#endif
|
||||
|
||||
/// Die Beschriftung eines Eintrags.
|
||||
///
|
||||
/// **Ein** Aufruf, nicht zwei. Es gab hier eine zweite Fassung für Titel,
|
||||
/// die vom Server kommen — mit `String` statt `LocalizedStringKey`. Swift
|
||||
/// darf sich bei einem Literal für beide entscheiden, und es entschied sich
|
||||
/// nicht überall gleich: in der Seitenleiste stand plötzlich
|
||||
/// „sidebar.downloads" statt „Downloads". Wer einen fertigen Text hat,
|
||||
/// baut sein `Text` jetzt selbst.
|
||||
private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View {
|
||||
sidebarLabel(Text(title), symbol: symbol)
|
||||
}
|
||||
|
||||
private func sidebarLabel(_ title: Text, symbol: String) -> some View {
|
||||
let label = Label { title } icon: { Image(systemName: symbol) }
|
||||
#if os(macOS)
|
||||
return label.frame(
|
||||
minWidth: Self.sidebarWidth.min - 40,
|
||||
maxWidth: Self.sidebarWidth.max - 40,
|
||||
alignment: .leading
|
||||
)
|
||||
#else
|
||||
return label
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Was in der Seitenleiste steht.
|
||||
///
|
||||
/// Die Mediatheken kommen erst zur Laufzeit vom Server, deshalb trägt
|
||||
/// `library` den Schlüssel des Abschnitts statt eines festen Falls.
|
||||
enum SidebarDestination: Hashable {
|
||||
case home
|
||||
case library(key: String, title: String)
|
||||
/// Alle Mediatheken zusammen — auf schmalen Bildschirmen an ihrer Stelle.
|
||||
case libraryHub
|
||||
case search
|
||||
case liveTV
|
||||
case downloads
|
||||
case settings
|
||||
|
||||
/// Eine feste Kennung zum Speichern.
|
||||
///
|
||||
/// **Ohne** den Titel: benennt jemand eine Mediathek auf dem Server um,
|
||||
/// soll sie ihren Platz in der Seitenleiste behalten und nicht als neuer
|
||||
/// Eintrag ganz unten auftauchen.
|
||||
var storageKey: String {
|
||||
switch self {
|
||||
case .home: "home"
|
||||
case .library(let key, _): "library:\(key)"
|
||||
case .libraryHub: "libraryHub"
|
||||
case .search: "search"
|
||||
case .liveTV: "liveTV"
|
||||
case .downloads: "downloads"
|
||||
case .settings: "settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Titel in der Navigationsleiste.
|
||||
///
|
||||
/// Auf der Startseite steht dort der Name der App — in der Seitenleiste
|
||||
/// wäre das nur eine Dopplung der Überschrift, deshalb hat sie mit
|
||||
/// `sidebarLabel` einen eigenen Text.
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: Branding.appName
|
||||
case .library(_, let title): title
|
||||
case .libraryHub: String(localized: "sidebar.library")
|
||||
case .search: String(localized: "search.title")
|
||||
case .liveTV: String(localized: "sidebar.liveTV")
|
||||
case .downloads: String(localized: "sidebar.downloads")
|
||||
case .settings: String(localized: "sidebar.settings")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Beschriftung in der Seitenleiste.
|
||||
var sidebarLabel: String {
|
||||
switch self {
|
||||
case .home: String(localized: "sidebar.home")
|
||||
default: title
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .home: "house"
|
||||
case .library: "film"
|
||||
case .libraryHub: "books.vertical"
|
||||
case .search: "magnifyingglass"
|
||||
case .liveTV: "dot.radiowaves.left.and.right"
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .settings: "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Weg zur Suche, wenn sie kein eigener Eintrag ist.
|
||||
///
|
||||
/// Auf dem iPhone hat die Leiste unten fünf Plätze, und einer davon für die
|
||||
/// Suche ist einer zu viel — sie sitzt dort, wo man sie sucht: über der
|
||||
/// Mediathek, neben Filter und Ansicht.
|
||||
struct SearchRoute: Hashable {}
|
||||
@@ -0,0 +1,81 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Blendet die vom System gezeichnete Seitenleiste und Tableiste aus.
|
||||
///
|
||||
/// Der Grund ist das Aussehen, nicht das Verhalten. `UITabBarController`
|
||||
/// zeichnet seine Seitenleiste auf dem iPad als schwebende Fläche und auf dem
|
||||
/// Mac angeklebt am Fensterrand — und das lässt sich nicht umstellen.
|
||||
/// `preferredLayout = .overlap` ist die dafür vorgesehene Bitte; macOS verwirft
|
||||
/// sie stillschweigend. Gemessen, nicht vermutet.
|
||||
///
|
||||
/// Statt deshalb die ganze Navigation selbst zu bauen, bleibt der
|
||||
/// `TabView` **stehen** und verliert nur sein Kleid: Auswahl, getrennte
|
||||
/// Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln,
|
||||
/// Zustandspflege — all das macht weiter das System. Gezeichnet wird darüber.
|
||||
///
|
||||
/// Dass das geht, war kein Ratschluss: derselbe Griff hat die Leiste
|
||||
/// versuchsweise schon einmal verschwinden lassen.
|
||||
struct TabChromeSuppressor: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController { Probe() }
|
||||
func updateUIViewController(_ controller: UIViewController, context: Context) {}
|
||||
|
||||
/// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen.
|
||||
private final class Probe: UIViewController {
|
||||
override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
suppress()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// Ein zweites Mal: beim ersten Anhängen steht der Tab-Controller
|
||||
// noch nicht zwingend im Baum.
|
||||
suppress()
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
// Und bei jedem Umbau: das System stellt die Leisten wieder her,
|
||||
// wenn sich die Größenklasse ändert — beim Drehen etwa.
|
||||
suppress()
|
||||
}
|
||||
|
||||
private func suppress() {
|
||||
guard let controller = Self.tabBarController(from: self) else { return }
|
||||
|
||||
// Erst die Form festlegen, dann ausblenden — und zwar auf die
|
||||
// Tableiste, nicht auf die Seitenleiste.
|
||||
//
|
||||
// Nur `sidebar.isHidden = true` reichte nicht: auf dem Mac stand
|
||||
// die System-Seitenleiste danach wieder da, mit eigenem Pfeil und
|
||||
// eigenem Knopf, hinter unserer. Solange `mode` auf `tabSidebar`
|
||||
// steht, gehört die Seitenleiste zur Darstellung und das System
|
||||
// stellt sie wieder her. Steht `mode` auf `tabBar`, gibt es keine
|
||||
// Seitenleiste mehr, die zurückkommen könnte — und die Tableiste
|
||||
// ist ausgeblendet.
|
||||
if controller.mode != .tabBar { controller.mode = .tabBar }
|
||||
if !controller.isTabBarHidden { controller.isTabBarHidden = true }
|
||||
if !controller.sidebar.isHidden { controller.sidebar.isHidden = true }
|
||||
}
|
||||
|
||||
/// Sucht vom eigenen Platz aus nach oben bis zur Wurzel und von dort
|
||||
/// nach unten: die Leiste liegt in der SwiftUI-Sicht *über* uns, die
|
||||
/// reine Elternkette führt an ihr vorbei.
|
||||
private static func tabBarController(from start: UIViewController) -> UITabBarController? {
|
||||
var root = start
|
||||
while let parent = root.parent { root = parent }
|
||||
return search(root)
|
||||
}
|
||||
|
||||
private static func search(_ controller: UIViewController) -> UITabBarController? {
|
||||
if let found = controller as? UITabBarController { return found }
|
||||
for child in controller.children {
|
||||
if let found = search(child) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// Was oben über dem Inhalt schwebt — und wie viel Platz es einnimmt.
|
||||
///
|
||||
/// Zwei Dinge, die zusammengehören und deshalb an einer Stelle liegen: die
|
||||
/// Höhe, die Statusleiste und Insel zusammen belegen, und ob die Insel gerade
|
||||
/// zu sehen ist.
|
||||
///
|
||||
/// Der Umweg über ein geteiltes Objekt ist nötig, weil beide Enden weit
|
||||
/// auseinanderliegen. Die Höhe kennt nur `RootView` — sie hängt am Gerät und
|
||||
/// daran, ob die Navigation an der Seite oder oben sitzt. Brauchen tut sie der
|
||||
/// Aufmacher, der unter ihr durchgreifen soll, und der sitzt drei Ebenen
|
||||
/// tiefer in einem eigenen Navigationsstapel. Umgekehrt weiß nur die
|
||||
/// Bildlaufansicht des gerade sichtbaren Bildschirms, ob man oben steht —
|
||||
/// entscheiden muss es `RootView`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TopChrome {
|
||||
/// Wie hoch Statusleiste und Insel zusammen bauen.
|
||||
///
|
||||
/// Der Wert, um den der Inhalt nach unten beginnt. Wer ihn überspringen
|
||||
/// und bis an den Bildschirmrand greifen will, zieht sich um genau diese
|
||||
/// Strecke nach oben.
|
||||
var inset: CGFloat = 0
|
||||
|
||||
/// Ob die Insel gerade zu sehen ist.
|
||||
///
|
||||
/// Steht man ganz oben, ist sie da. Beim Scrollen fährt sie weg — der
|
||||
/// Inhalt ist dann das Wichtigere, und die Navigation kommt zurück, sobald
|
||||
/// man wieder oben ankommt.
|
||||
var isRevealed = true
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Meldet dieser Bildlaufansicht an, ob sie ganz oben steht.
|
||||
///
|
||||
/// Gehört an die **äußere, senkrechte** Bildlaufansicht eines Bildschirms.
|
||||
/// Nicht an die waagerechten Reihen darin: `onScrollGeometryChange` meldet
|
||||
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
|
||||
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
|
||||
/// weit unten steht.
|
||||
func haloRevealsTopChrome() -> some View {
|
||||
modifier(RevealsTopChrome())
|
||||
}
|
||||
}
|
||||
|
||||
private struct RevealsTopChrome: ViewModifier {
|
||||
@Environment(TopChrome.self) private var chrome
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
// Der Sicherheitsrand gehört dazugerechnet: `contentOffset` allein
|
||||
// beginnt bei einer Ansicht mit Rand oben im Negativen, und „ganz
|
||||
// oben" wäre dann nie null.
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.y + geometry.contentInsets.top
|
||||
} action: { _, offset in
|
||||
// Eine Schwelle statt genau null: das Gummiband am oberen Ende
|
||||
// liefert auch im Ruhezustand Werte, die um Bruchteile eines
|
||||
// Punktes schwanken, und die Insel flackerte sonst.
|
||||
chrome.isRevealed = offset <= 1
|
||||
}
|
||||
// Wer einen Bildschirm verlässt, während er weit unten steht, käme
|
||||
// sonst auf dem nächsten ohne Navigation an.
|
||||
.onDisappear { chrome.isRevealed = true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Ein noch nicht gebauter Bereich.
|
||||
///
|
||||
/// Gibt es nur in Phase 0, damit die Navigation vollständig begehbar ist,
|
||||
/// bevor es Inhalte gibt. Jede Phase ersetzt einen dieser Bildschirme durch
|
||||
/// den echten.
|
||||
struct PlaceholderScreen: View {
|
||||
let destination: SidebarDestination
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label(destination.title, systemImage: destination.symbol)
|
||||
} description: {
|
||||
Text("placeholder.description")
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(destination.title)
|
||||
.haloInlineTitle()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Der Knopf, der die Empfängerliste öffnet.
|
||||
///
|
||||
/// Kein eigener Knopf mit eigener Liste: `AVRoutePickerView` ist die Stelle,
|
||||
/// die das System dafür vorsieht, und sie kennt Geräte, die eine App nie
|
||||
/// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund.
|
||||
/// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen.
|
||||
#if canImport(UIKit)
|
||||
struct AirPlayButton: UIViewRepresentable {
|
||||
let tint: Color
|
||||
|
||||
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.tintColor = .white
|
||||
view.activeTintColor = UIColor(tint)
|
||||
// Bild vor Ton: bei einem Film soll die Liste die Fernseher zuerst
|
||||
// zeigen, nicht die Lautsprecher.
|
||||
view.prioritizesVideoDevices = true
|
||||
view.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: AVRoutePickerView, context: Context) {
|
||||
view.activeTintColor = UIColor(tint)
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AirPlayButton: NSViewRepresentable {
|
||||
let tint: Color
|
||||
|
||||
func makeNSView(context: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.isRoutePickerButtonBordered = false
|
||||
view.setRoutePickerButtonColor(.white, for: .normal)
|
||||
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: AVRoutePickerView, context: Context) {
|
||||
view.setRoutePickerButtonColor(NSColor(tint), for: .active)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,38 @@
|
||||
import UIKit
|
||||
|
||||
/// Das Fenster auf dem Mac ins Vollbild und zurück.
|
||||
///
|
||||
/// **Und warum das umständlich ist.** Für eine „für iPad angepasste" App gibt
|
||||
/// es dafür keine öffentliche Schnittstelle. UIKit kennt `isFullScreen` nur
|
||||
/// lesend und nur für Mac Catalyst; einen Umschalter gibt es nirgends. Das
|
||||
/// Fenster gehört AppKit, und an AppKit kommt diese App nicht heran.
|
||||
///
|
||||
/// Was bleibt, ist die **Antwortkette**: `toggleFullScreen:` ist die Aktion,
|
||||
/// die auch der grüne Knopf und ⌃⌘F auslösen, und UIKit reicht unbeantwortete
|
||||
/// Aktionen an das tragende AppKit-Fenster weiter. Ob es das tut, ist
|
||||
/// nirgends zugesagt.
|
||||
///
|
||||
/// **Deshalb wird vorher gefragt, nicht hinterher gehofft.**
|
||||
/// `target(forAction:)` sagt, ob die Aktion jemand entgegennimmt — **ohne sie
|
||||
/// auszulösen**. Erst wenn es einen Empfänger gibt, erscheint der Knopf. Ein
|
||||
/// Knopf, der nichts tut, ist schlimmer als keiner: er lässt den Nutzer an
|
||||
/// sich zweifeln statt an der Plattform.
|
||||
@MainActor
|
||||
enum MacFullScreen {
|
||||
private static let action = NSSelectorFromString("toggleFullScreen:")
|
||||
|
||||
/// Ob es hier überhaupt jemanden gibt, der umschalten kann.
|
||||
///
|
||||
/// Bei jedem Zeichnen gefragt statt einmal gemerkt: die Antwortkette
|
||||
/// hängt am gerade aktiven Fenster, und ein einmal ermittelter Wert wäre
|
||||
/// falsch, sobald es keins gibt — etwa vor dem ersten Erscheinen.
|
||||
static var isAvailable: Bool {
|
||||
UIApplication.shared.target(forAction: action, withSender: nil) != nil
|
||||
}
|
||||
|
||||
/// Schaltet um. Gibt zurück, ob jemand zugehört hat.
|
||||
@discardableResult
|
||||
static func toggle() -> Bool {
|
||||
UIApplication.shared.sendAction(action, to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Die schwebende Videokachel.
|
||||
///
|
||||
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
|
||||
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
|
||||
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
|
||||
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
|
||||
///
|
||||
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
|
||||
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
|
||||
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
|
||||
struct MiniPlayer: View {
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
|
||||
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
|
||||
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
|
||||
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
|
||||
/// Stelle, statt unten herauszufallen.
|
||||
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
|
||||
|
||||
/// Verschiebung während des Ziehens, noch nicht eingerastet.
|
||||
@State private var drag: CGSize = .zero
|
||||
|
||||
private var side: MiniPlayerPlacement.Side {
|
||||
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
|
||||
}
|
||||
|
||||
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
||||
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
||||
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
|
||||
@State private var zoom: CGFloat = 1
|
||||
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
|
||||
///
|
||||
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
|
||||
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
|
||||
@State private var lastWidth: CGFloat = 0
|
||||
|
||||
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
|
||||
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
|
||||
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
|
||||
|
||||
private func tileSize(in bounds: CGSize) -> CGSize {
|
||||
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
|
||||
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
|
||||
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
||||
let upper = max(160, min(bounds.width * 0.75, 560))
|
||||
let width = min(max(chosen * zoom, 140), upper)
|
||||
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let tileSize = tileSize(in: geometry.size)
|
||||
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
|
||||
let resting = placement.center(side: side, verticalFraction: storedFraction)
|
||||
let position = placement.clamped(
|
||||
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
||||
)
|
||||
|
||||
tile
|
||||
.frame(width: tileSize.width, height: tileSize.height)
|
||||
.onChange(of: tileSize.width, initial: true) { _, width in
|
||||
lastWidth = width
|
||||
}
|
||||
.position(position)
|
||||
.gesture(dragGesture(in: placement, from: resting))
|
||||
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
|
||||
// Loslassen greift — beides passiert mit denselben Fingern.
|
||||
.simultaneousGesture(zoomGesture(in: geometry.size))
|
||||
// Nur während des Ziehens ohne Animation, sonst würde die
|
||||
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
|
||||
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
|
||||
}
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var tile: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
VideoSurface(
|
||||
engine: model.engine,
|
||||
surface: model.surface,
|
||||
isActive: playback.presentation == .mini
|
||||
)
|
||||
|
||||
if model.isBuffering {
|
||||
ProgressView().tint(.white)
|
||||
}
|
||||
|
||||
corners
|
||||
|
||||
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
|
||||
// es gibt kein Ende, auf das er zulaufen könnte.
|
||||
if !model.isLive, let fraction = progress {
|
||||
VStack {
|
||||
Spacer()
|
||||
WatchProgressBar(fraction: fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||||
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
||||
.contentShape(.rect)
|
||||
// Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen:
|
||||
// die erste holte die Bedienelemente hervor, die zweite sollte
|
||||
// öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte
|
||||
// lag. Vergrößern war damit überhaupt nicht möglich.
|
||||
.onTapGesture { playback.expand() }
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(Text(model.title))
|
||||
}
|
||||
|
||||
/// Die beiden Knöpfe in den oberen Ecken.
|
||||
///
|
||||
/// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte
|
||||
/// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel
|
||||
/// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein.
|
||||
/// Unten bleibt frei für den Fortschrittsbalken.
|
||||
private var corners: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
tileButton(
|
||||
model.isPlaying ? "pause.fill" : "play.fill",
|
||||
label: model.isPlaying ? "player.pause" : "player.play"
|
||||
) {
|
||||
model.togglePlayPause()
|
||||
}
|
||||
Spacer()
|
||||
tileButton("xmark", label: "miniPlayer.close") {
|
||||
playback.close()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
|
||||
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
|
||||
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
|
||||
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
|
||||
tileButton(
|
||||
"arrow.up.left.and.arrow.down.right",
|
||||
label: "miniPlayer.expand"
|
||||
) {
|
||||
playback.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(HaloSpacing.tight)
|
||||
// Über dem Fortschrittsbalken, nicht darauf.
|
||||
.padding(.bottom, HaloSpacing.tight)
|
||||
}
|
||||
|
||||
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
||||
///
|
||||
/// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf
|
||||
/// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren
|
||||
/// Helligkeit sich jede Sekunde ändert.
|
||||
private func tileButton(
|
||||
_ symbol: String,
|
||||
label: LocalizedStringKey,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(.black.opacity(0.55), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
|
||||
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
|
||||
///
|
||||
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
|
||||
/// und stritt sich mit dem Verschieben um dieselbe Geste.
|
||||
private func zoomGesture(in bounds: CGSize) -> some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
zoom = value.magnification
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Die **begrenzte** Breite merken, nicht die gezogene: eine
|
||||
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
|
||||
// groß, auch auf einem kleineren Bildschirm.
|
||||
storedWidth = Double(lastWidth)
|
||||
zoom = 1
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: Double? {
|
||||
guard let duration = model.duration, duration.seconds > 0 else { return nil }
|
||||
return model.currentTime.seconds / duration.seconds
|
||||
}
|
||||
|
||||
private func dragGesture(
|
||||
in placement: MiniPlayerPlacement,
|
||||
from resting: CGPoint
|
||||
) -> some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
drag = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
|
||||
// dem Finger: sonst springt eine Kachel, die man am Rand
|
||||
// angefasst hat, auf die falsche Seite.
|
||||
let center = placement.clamped(center: CGPoint(
|
||||
x: resting.x + value.predictedEndTranslation.width,
|
||||
y: resting.y + value.predictedEndTranslation.height
|
||||
))
|
||||
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
|
||||
// wohin man sie gezogen hat.
|
||||
storedSide = placement.nearestSide(toCenter: center).rawValue
|
||||
storedFraction = placement.verticalFraction(forCenter: center)
|
||||
drag = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Legt die schwebende Kachel über diesen Bildschirm.
|
||||
///
|
||||
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
|
||||
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
|
||||
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
|
||||
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
|
||||
overlay {
|
||||
if let model, isVisible {
|
||||
MiniPlayer(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||
///
|
||||
/// **Warum nicht `Slider`.** Ein `Slider` hat genau eine Spur, und die gehört
|
||||
/// dem Fortschritt. Was bereits geladen ist, lässt sich dort nicht
|
||||
/// unterbringen — und das ist die Auskunft, um die es hier geht: wie weit
|
||||
/// kann ich springen, ohne zu warten. Dahinter etwas zu zeichnen und auf
|
||||
/// gleiche Ausrichtung zu hoffen ginge so lange gut, bis Apple die
|
||||
/// Innenabstände ändert.
|
||||
///
|
||||
/// Drei Schichten übereinander, alle aus derselben Rechnung:
|
||||
///
|
||||
/// Grund ── die ganze Länge, gedämpft
|
||||
/// Geladen ── bis wohin Daten da sind, heller
|
||||
/// Gespielt ── bis zur Abspielstelle, in der Akzentfarbe
|
||||
///
|
||||
/// **Der Griff ist grösser als der Strich.** Vier Punkte hoch trifft niemand
|
||||
/// mit dem Finger; der unsichtbare Bereich darüber ist deshalb so hoch wie
|
||||
/// eine Zeile.
|
||||
struct PlaybackScrubber: View {
|
||||
/// Wo abgespielt wird.
|
||||
let position: Duration
|
||||
/// Wie lang das Ganze ist.
|
||||
let duration: Duration
|
||||
/// Bis wohin geladen ist — `nil`, wenn es niemand weiss.
|
||||
let buffered: Duration?
|
||||
/// Ob gerade gezogen wird; dann bleibt der Griff gross.
|
||||
@Binding var scrubbing: Double?
|
||||
|
||||
/// Beim Loslassen, mit dem Ziel.
|
||||
let onCommit: (Duration) -> Void
|
||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||
let onActivity: () -> Void
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
private static let trackHeight: CGFloat = 5
|
||||
private static let thumbSize: CGFloat = 13
|
||||
/// So hoch, dass ein Finger ihn trifft.
|
||||
private static let hitHeight: CGFloat = 34
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let breite = geometry.size.width
|
||||
let gespielt = anteil(of: angezeigt) * breite
|
||||
let geladen = buffered.map { anteil(of: $0) * breite } ?? 0
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.22))
|
||||
.frame(height: Self.trackHeight)
|
||||
|
||||
// Nur wenn er weiter reicht als das Gespielte. Sonst sähe man
|
||||
// einen Balken, der hinter der Abspielstelle zurückbleibt —
|
||||
// und das behauptete etwas Falsches.
|
||||
if geladen > gespielt {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.45))
|
||||
.frame(width: geladen, height: Self.trackHeight)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(accent)
|
||||
.frame(width: gespielt, height: Self.trackHeight)
|
||||
|
||||
Circle()
|
||||
.fill(accent)
|
||||
.frame(width: scrubbing == nil ? Self.thumbSize : Self.thumbSize * 1.5)
|
||||
.offset(x: gespielt - Self.thumbSize / 2)
|
||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||
}
|
||||
.frame(height: Self.hitHeight)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { wert in
|
||||
scrubbing = sekunden(at: wert.location.x, width: breite)
|
||||
onActivity()
|
||||
}
|
||||
.onEnded { wert in
|
||||
let ziel = sekunden(at: wert.location.x, width: breite)
|
||||
scrubbing = nil
|
||||
onCommit(.seconds(ziel))
|
||||
onActivity()
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: Self.hitHeight)
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(Text("player.position"))
|
||||
.accessibilityValue(Text(angezeigt.timecode))
|
||||
.accessibilityAdjustableAction { richtung in
|
||||
let schritt = Duration.seconds(15)
|
||||
switch richtung {
|
||||
case .increment: onCommit(min(angezeigt + schritt, duration))
|
||||
case .decrement: onCommit(max(.zero, angezeigt - schritt))
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was der Regler zeigt: beim Ziehen der Finger, sonst die Wiedergabe.
|
||||
private var angezeigt: Duration {
|
||||
scrubbing.map(Duration.seconds) ?? position
|
||||
}
|
||||
|
||||
private func anteil(of zeit: Duration) -> CGFloat {
|
||||
let gesamt = duration.seconds
|
||||
guard gesamt > 0 else { return 0 }
|
||||
return min(max(CGFloat(zeit.seconds / gesamt), 0), 1)
|
||||
}
|
||||
|
||||
private func sekunden(at x: CGFloat, width: CGFloat) -> Double {
|
||||
guard width > 0 else { return 0 }
|
||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
|
||||
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
||||
///
|
||||
/// Ein Sheet statt eines Dialogs: die Leiter hat je nach Quelle bis zu
|
||||
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
|
||||
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
|
||||
/// was gerade über die Leitung kommt.
|
||||
struct QualitySheet: View {
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
/// **Als Fragezeichen**, nicht als Behauptung.
|
||||
///
|
||||
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
|
||||
/// an, wenn der Gegenstand nicht im Baum steht — und ein Sheet hängt an
|
||||
/// einem eigenen Baum. Genau daran ist der Player eingefroren, und zu
|
||||
/// sehen war nichts davon. Der Player reicht die Einstellungen jetzt
|
||||
/// ausdrücklich herein; das Fragezeichen sorgt dafür, dass ein Vergessen
|
||||
/// künftig nur eine fehlende Zeile kostet statt der ganzen App.
|
||||
@Environment(PlaybackSettings.self) private var settings: PlaybackSettings?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showsEveryRung = false
|
||||
|
||||
private var everyday: [PlayerQuality] {
|
||||
PlayerQuality.everyday(forSourceHeight: model.sourceHeight)
|
||||
}
|
||||
|
||||
private var options: [PlayerQuality] {
|
||||
PlayerQuality.available(forSourceHeight: model.sourceHeight)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
current
|
||||
|
||||
Section {
|
||||
ForEach(everyday) { option in
|
||||
row(for: option)
|
||||
}
|
||||
} footer: {
|
||||
Text("quality.audioOnly.hint")
|
||||
}
|
||||
|
||||
// Aufgeklappt nur, wer es wirklich will. Für den Alltag
|
||||
// reicht „eine Stufe kleiner"; die volle Leiter braucht, wer
|
||||
// eine bestimmte Bitrate erzwingen muss.
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $showsEveryRung) {
|
||||
ForEach(groupedHeights, id: \.self) { height in
|
||||
ForEach(rungs(height: height), id: \.self) { rung in
|
||||
row(for: .limited(rung), detail: "\(height)p")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("quality.everyRung")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("player.quality"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
|
||||
/// Was gerade läuft — gemessen, nicht gewünscht.
|
||||
@ViewBuilder
|
||||
private var current: some View {
|
||||
if settings?.showsBitrate == true, let statistics = model.statistics {
|
||||
Section {
|
||||
if let observed = statistics.observedBitrate {
|
||||
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
|
||||
}
|
||||
if let resolution = statistics.resolution {
|
||||
LabeledContent("quality.current.resolution", value: resolution)
|
||||
}
|
||||
if let dropped = statistics.droppedFrames, dropped > 0 {
|
||||
LabeledContent("quality.current.dropped", value: "\(dropped)")
|
||||
}
|
||||
|
||||
// **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist
|
||||
// das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur
|
||||
// ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der
|
||||
// läuft und nichts bekommt.
|
||||
if let vorrat = model.prefetchStatus {
|
||||
LabeledContent("quality.current.prefetch", value: vorrat)
|
||||
}
|
||||
|
||||
// **Die Zahl, die alles entscheidet.** Eine Playlist mit
|
||||
// einer Stufe hat nichts zum Umschalten — dann greift jedes
|
||||
// Nachregeln ins Leere, ganz gleich was bestellt wurde.
|
||||
if let variants = statistics.variantCount {
|
||||
LabeledContent("quality.current.variants", value: "\(variants)")
|
||||
}
|
||||
|
||||
// Der Puffer ist der einzige echte Frühwarner: schrumpft er
|
||||
// schneller, als die Zeit vergeht, kommt weniger herein als
|
||||
// hinausgeht.
|
||||
if let buffer = statistics.bufferedAhead {
|
||||
LabeledContent("quality.current.buffer", value: secondsText(buffer))
|
||||
}
|
||||
|
||||
if statistics.stallCount > 0 {
|
||||
LabeledContent(
|
||||
"quality.current.stalls",
|
||||
value: String(
|
||||
localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))"
|
||||
)
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("quality.current")
|
||||
} footer: {
|
||||
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
|
||||
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
|
||||
// trotz hoher Stufe, steht die Antwort hier.
|
||||
//
|
||||
// Und wenn es nur eine Stufe gibt, gehört auch das gesagt:
|
||||
// sonst sucht man den Fehler beim Player, während in
|
||||
// Wahrheit nie eine Leiter bestellt wurde.
|
||||
if statistics.variantCount == 1 {
|
||||
Text("quality.current.noLadder")
|
||||
} else {
|
||||
Text("quality.current.hint")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var groupedHeights: [Int] {
|
||||
var seen = Set<Int>()
|
||||
return options.compactMap { option -> Int? in
|
||||
guard case .limited(let rung) = option else { return nil }
|
||||
return seen.insert(rung.height).inserted ? rung.height : nil
|
||||
}
|
||||
}
|
||||
|
||||
private func rungs(height: Int) -> [PlayerQuality.Rung] {
|
||||
options.compactMap { option in
|
||||
guard case .limited(let rung) = option, rung.height == height else { return nil }
|
||||
return rung
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for option: PlayerQuality, detail: String? = nil) -> some View {
|
||||
Button {
|
||||
Task { await model.select(quality: option) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label(for: option))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
Spacer()
|
||||
if option == model.quality {
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func label(for option: PlayerQuality) -> String {
|
||||
switch option {
|
||||
case .automatic: String(localized: "quality.automatic")
|
||||
case .original: String(localized: "quality.original")
|
||||
case .audioOnly: String(localized: "quality.audioOnly")
|
||||
// In der kurzen Liste zählt die Auflösung, nicht die Zahl dahinter:
|
||||
// „720p" sagt mehr als „3 Mbps", wenn man nur kleiner will.
|
||||
case .limited(let rung): "\(rung.height)p · \(bitrateText(rung.bitrate))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit.
|
||||
private func secondsText(_ duration: Duration) -> String {
|
||||
let seconds = Double(duration.components.seconds)
|
||||
+ Double(duration.components.attoseconds) / 1e18
|
||||
return String(localized: "quality.seconds \(String(format: "%.1f", seconds))")
|
||||
}
|
||||
|
||||
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
|
||||
///
|
||||
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
|
||||
/// schlechter als „64 kbps".
|
||||
private func bitrateText(_ kilobits: Int) -> String {
|
||||
guard kilobits >= 1_000 else {
|
||||
return String(localized: "quality.kilobits \(kilobits)")
|
||||
}
|
||||
let megabits = Double(kilobits) / 1_000
|
||||
let rounded = megabits.rounded() == megabits
|
||||
? String(Int(megabits))
|
||||
: String(format: "%.1f", megabits)
|
||||
return String(localized: "quality.megabits \(rounded)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
||||
///
|
||||
/// Getrennt, weil es zwei verschiedene Fragen sind: die Tonspur wechselt man
|
||||
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Sheet
|
||||
/// bedeutete jedes Mal Scrollen an der anderen Liste vorbei.
|
||||
struct TrackPickerSheet: View {
|
||||
enum Kind: Identifiable {
|
||||
case audio
|
||||
case subtitles
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .audio: "player.audioTracks"
|
||||
case .subtitles: "player.subtitleTracks"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model: PlayerModel
|
||||
let kind: Kind
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
switch kind {
|
||||
case .audio where model.isOffline:
|
||||
// **Offline zählt, was in der Datei liegt.** Die Liste des
|
||||
// Servers gibt es hier nicht: `chooseAudioStream` läuft nur
|
||||
// auf dem Online-Weg. Solange sie trotzdem gefragt wurde,
|
||||
// blieb die Auswahl für heruntergeladene Titel leer — der
|
||||
// Knopf war da, das Sheet zeigte nichts.
|
||||
ForEach(model.audioTracks) { track in
|
||||
row(title: track.displayName, isSelected: model.selectedAudioTrack == track) {
|
||||
model.selectAudioTrack(track)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
case .audio:
|
||||
// **Die Spuren der Datei, nicht die des Players.** Rechnet
|
||||
// der Server um, kennt der Player genau eine — die, für
|
||||
// die er sich entschieden hat. Zur Auswahl steht, was in
|
||||
// der Datei liegt.
|
||||
ForEach(model.audioStreams) { stream in
|
||||
row(
|
||||
title: stream.name,
|
||||
isSelected: model.selectedAudioStream == stream
|
||||
) {
|
||||
Task { await model.selectAudioStream(stream) }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if let gemerkt = model.rememberedTrackName {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
model.forgetRememberedTrack()
|
||||
} label: {
|
||||
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
} header: {
|
||||
Text("player.rememberedTrack")
|
||||
} footer: {
|
||||
// Eine automatische Handlung, die man nicht sehen
|
||||
// kann, ist von einem Fehler nicht zu
|
||||
// unterscheiden.
|
||||
Text("player.rememberedTrackExplanation \(gemerkt)")
|
||||
}
|
||||
}
|
||||
|
||||
case .subtitles where model.isOffline:
|
||||
row(
|
||||
title: String(localized: "player.subtitlesOff"),
|
||||
isSelected: model.selectedSubtitleTrack == nil
|
||||
) {
|
||||
model.selectSubtitleTrack(nil)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
ForEach(model.subtitleTracks) { track in
|
||||
row(title: track.displayName, isSelected: model.selectedSubtitleTrack == track) {
|
||||
model.selectSubtitleTrack(track)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
case .subtitles:
|
||||
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht als
|
||||
// eigener Schalter daneben: es ist eine Auswahl unter
|
||||
// mehreren, keine getrennte Eigenschaft.
|
||||
row(
|
||||
title: String(localized: "player.subtitlesOff"),
|
||||
isSelected: model.selectedSubtitleStream == nil
|
||||
) {
|
||||
Task { await model.selectSubtitleStream(nil) }
|
||||
dismiss()
|
||||
}
|
||||
|
||||
// Wie beim Ton die Spuren der Datei: ein umgerechneter
|
||||
// Stream bringt genau einen mit, und der heisst „CC".
|
||||
ForEach(model.subtitleStreams) { stream in
|
||||
row(
|
||||
title: stream.name,
|
||||
isSelected: model.selectedSubtitleStream == stream
|
||||
) {
|
||||
Task { await model.selectSubtitleStream(stream) }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text(kind.title))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloMediumSheet()
|
||||
}
|
||||
|
||||
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import HaloPlayback
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Die Ansichtsklasse der jeweiligen Plattform.
|
||||
///
|
||||
/// UIKit und AppKit heißen verschieden und meinen dasselbe. Ein Name dafür
|
||||
/// spart in dieser Datei ein Dutzend `#if`.
|
||||
#if canImport(UIKit)
|
||||
typealias PlatformView = UIView
|
||||
#else
|
||||
typealias PlatformView = NSView
|
||||
#endif
|
||||
|
||||
/// Die Bildfläche der laufenden Engine.
|
||||
struct VideoSurface: View {
|
||||
let engine: (any VideoPlayerEngine)?
|
||||
/// Die Bildebene des Systemplayers, gehalten vom Modell.
|
||||
///
|
||||
/// **Nicht** hier gebaut. Eine `AVPlayerLayer`, die in `makeUIView`
|
||||
/// entsteht, stirbt mit jedem Identitätswechsel der Ansicht — und genau
|
||||
/// das passiert beim Wechsel zwischen Kachel und Vollbild. Das Bild
|
||||
/// setzte dann jedes Mal neu an, sichtbar als Zucken und Schwarzbild.
|
||||
let surface: PlayerLayerView
|
||||
|
||||
/// Ob **diese** Ansicht die Bildfläche gerade haben soll.
|
||||
///
|
||||
/// **Der Kern des Schwarzbilds.** Kachel und Vollbild bestehen beim
|
||||
/// Wechsel kurz gleichzeitig, und beide holen sich die Fläche in ihre
|
||||
/// Hülle. Eine Ansicht hat aber nur einen Vorgesetzten — wer zuletzt
|
||||
/// aktualisiert, gewinnt, und das war nicht verlässlich die richtige.
|
||||
/// Blieb die Fläche bei der Kachel, die gleich darauf verschwand, lief
|
||||
/// im Vollbild der Ton weiter und das Bild blieb schwarz.
|
||||
///
|
||||
/// Die Hülle allein genügte dagegen nicht: sie verhindert, dass das
|
||||
/// Abräumen der einen der anderen etwas wegnimmt, entscheidet aber
|
||||
/// nicht, wer sie bekommen **soll**. Das steht hier.
|
||||
var isActive = true
|
||||
|
||||
/// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden.
|
||||
///
|
||||
/// Hier steht nur die Zahl; wann sie ungleich null ist, entscheidet
|
||||
/// `PlayerView.surface(for:)`.
|
||||
var bottomInset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
if engine is AVPlayerEngine {
|
||||
SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset)
|
||||
} else {
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
|
||||
///
|
||||
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
|
||||
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
|
||||
/// UIKit.
|
||||
#if canImport(UIKit)
|
||||
final class PlayerLayerView: UIView {
|
||||
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
|
||||
/// Die Fläche, auf der wirklich Bild steht — ohne die schwarzen Ränder.
|
||||
///
|
||||
/// Vom Player ausgerechnet, aus dem, was gerade ankommt. Vor dem ersten
|
||||
/// Bild ist sie leer; wer sie benutzt, muss das prüfen.
|
||||
var videoRect: CGRect { playerLayer.videoRect }
|
||||
}
|
||||
#else
|
||||
/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene
|
||||
/// zugewiesen, statt ihre Art vorzugeben.
|
||||
final class PlayerLayerView: NSView {
|
||||
let playerLayer = AVPlayerLayer()
|
||||
|
||||
/// Siehe die UIKit-Fassung oben.
|
||||
var videoRect: CGRect { playerLayer.videoRect }
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
layer = playerLayer
|
||||
// Ohne das bleibt die Ebene beim Vergrößern des Fensters stehen, wo
|
||||
// sie war, und das Bild sitzt in der Ecke.
|
||||
layer?.needsDisplayOnBoundsChange = true
|
||||
autoresizingMask = [.width, .height]
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
|
||||
///
|
||||
/// Umhängen statt neu bauen: dieselbe `UIView` wandert zwischen Kachel und
|
||||
/// Vollbild hin und her, und die Wiedergabe merkt davon nichts.
|
||||
///
|
||||
/// Sie wird dabei **nicht** unmittelbar zurückgegeben, sondern in eine eigene
|
||||
/// Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es beide
|
||||
/// Ansichten kurz gleichzeitig gibt — beim Wechsel zwischen Kachel und
|
||||
/// Vollbild: eine `UIView` hat nur einen Vorgesetzten, und wer sie zuletzt
|
||||
/// bekommt, gewinnt. Gab SwiftUI sie unmittelbar heraus, nahm das Abräumen der
|
||||
/// einen Ansicht der anderen die Bildfläche weg, und übrig blieb eine schwarze
|
||||
/// Kachel — in einer fast schwarzen Oberfläche also scheinbar gar keine.
|
||||
///
|
||||
/// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt
|
||||
/// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde.
|
||||
#if canImport(UIKit)
|
||||
private struct SurfaceContainer: UIViewRepresentable {
|
||||
let view: PlatformView
|
||||
let isActive: Bool
|
||||
let bottomInset: CGFloat
|
||||
|
||||
func makeUIView(context: Context) -> SurfaceHostView {
|
||||
let host = SurfaceHostView()
|
||||
host.backgroundColor = .black
|
||||
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||
if isActive { host.adopt(view) }
|
||||
return host
|
||||
}
|
||||
|
||||
func updateUIView(_ host: SurfaceHostView, context: Context) {
|
||||
host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||
// Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen
|
||||
// sich beide Ansichten dieselbe Fläche, und die Reihenfolge
|
||||
// entscheidet — mal richtig, mal schwarz.
|
||||
guard isActive, view.superview !== host else { return }
|
||||
host.adopt(view)
|
||||
}
|
||||
}
|
||||
#else
|
||||
private struct SurfaceContainer: NSViewRepresentable {
|
||||
let view: PlatformView
|
||||
let isActive: Bool
|
||||
let bottomInset: CGFloat
|
||||
|
||||
func makeNSView(context: Context) -> SurfaceHostView {
|
||||
let host = SurfaceHostView()
|
||||
host.wantsLayer = true
|
||||
host.layer?.backgroundColor = NSColor.black.cgColor
|
||||
host.bottomInset = bottomInset
|
||||
if isActive { host.adopt(view) }
|
||||
return host
|
||||
}
|
||||
|
||||
func updateNSView(_ host: SurfaceHostView, context: Context) {
|
||||
host.bottomInset = bottomInset
|
||||
guard isActive, view.superview !== host else { return }
|
||||
host.adopt(view)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Die Hülle, die der Bildfläche ihren Platz zuweist.
|
||||
///
|
||||
/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die
|
||||
/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält
|
||||
/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse
|
||||
/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand
|
||||
/// verlangt echtes Layout — und das sind genau diese vier Zeilen.
|
||||
#if canImport(UIKit)
|
||||
final class SurfaceHostView: UIView {
|
||||
/// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`.
|
||||
var contentInset: UIEdgeInsets = .zero {
|
||||
didSet {
|
||||
guard contentInset != oldValue else { return }
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`.
|
||||
func adopt(_ view: UIView) {
|
||||
view.translatesAutoresizingMaskIntoConstraints = true
|
||||
view.autoresizingMask = []
|
||||
addSubview(view)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
subviews.first?.frame = bounds.inset(by: contentInset)
|
||||
}
|
||||
}
|
||||
#else
|
||||
final class SurfaceHostView: NSView {
|
||||
var bottomInset: CGFloat = 0 {
|
||||
didSet {
|
||||
guard bottomInset != oldValue else { return }
|
||||
needsLayout = true
|
||||
}
|
||||
}
|
||||
|
||||
func adopt(_ view: NSView) {
|
||||
view.translatesAutoresizingMaskIntoConstraints = true
|
||||
view.autoresizingMask = []
|
||||
addSubview(view)
|
||||
needsLayout = true
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
// Auf dem Mac liegt der Ursprung unten links: einzurücken heisst
|
||||
// hier, den Ursprung anzuheben und die Höhe zu kürzen.
|
||||
var rahmen = bounds
|
||||
rahmen.origin.y += bottomInset
|
||||
rahmen.size.height -= bottomInset
|
||||
subviews.first?.frame = rahmen
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Sucht über alle Mediatheken des Servers.
|
||||
///
|
||||
/// **Nur noch Darstellung.** Entprellen, fragen und merken liegen in
|
||||
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
|
||||
/// stellt. Siehe dort, warum das kein Beiwerk ist.
|
||||
struct SearchView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
|
||||
@State private var text = ""
|
||||
@State private var feed = SearchFeed()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if text.isEmpty {
|
||||
recentSearches
|
||||
} else if feed.hubs.isEmpty && !feed.isSearching {
|
||||
ContentUnavailableView.search(text: text)
|
||||
} else {
|
||||
results
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("search.title"))
|
||||
.haloInlineTitle()
|
||||
.searchable(text: $text)
|
||||
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
||||
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
||||
// von denen elf verworfen werden.
|
||||
.task(id: text) { await feed.search(text, from: session.backend) }
|
||||
// **Hier fehlte er.** Startseite und Mediathek hinterlegen das längst;
|
||||
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
|
||||
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
|
||||
// aus, als wäre nichts passiert.
|
||||
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) })
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
ForEach(feed.hubs) { hub in
|
||||
// **Antippen führt auf die Seite, nicht in den Film.** Die
|
||||
// Suche benutzt dieselbe Reihe wie die Startseite — und
|
||||
// damit war sie bisher auch dieselbe Geste. Wer sucht,
|
||||
// weiß aber noch nicht, was er sehen will; der erste
|
||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||
// weiter, über langes Drücken.
|
||||
MediaRow(row: hub, opensDetails: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.loose)
|
||||
}
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentSearches: some View {
|
||||
if feed.recent.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("search.title", systemImage: "magnifyingglass")
|
||||
} description: {
|
||||
Text("search.hint")
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ForEach(feed.recent, id: \.self) { entry in
|
||||
Button {
|
||||
text = entry
|
||||
} label: {
|
||||
Label(entry, systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("search.recent")
|
||||
} footer: {
|
||||
Button("search.forgetRecent", role: .destructive) {
|
||||
feed.forgetRecent()
|
||||
}
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Die Farbeinstellungen.
|
||||
///
|
||||
/// Kuratierte Kacheln plus eine freie Farbe. Die kuratierten sind so gewählt,
|
||||
/// dass sie auf dem dunklen Grund ohne Nachhilfe lesbar sind; bei einer freien
|
||||
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
||||
/// stillschweigend zu verändern.
|
||||
struct AppearanceView: View {
|
||||
@Environment(HaloTheme.self) private var theme
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@State private var customHex: String = ""
|
||||
@State private var showsCustomField = false
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
|
||||
ForEach(AccentOption.palette) { option in
|
||||
swatch(option)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
} header: {
|
||||
Text("appearance.accent")
|
||||
} footer: {
|
||||
Text("appearance.accentExplanation")
|
||||
}
|
||||
|
||||
// Die Kachelgröße steht bei der Darstellung und nicht bei der
|
||||
// Startseite: sie gilt für Reihen **und** Mediathek, und es geht
|
||||
// ums Aussehen, nicht um den Aufbau einer bestimmten Seite.
|
||||
Section {
|
||||
@Bindable var layout = layout
|
||||
|
||||
LabeledContent {
|
||||
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("appearance.cardScale")
|
||||
}
|
||||
Slider(
|
||||
value: $layout.cardScale,
|
||||
in: HomeLayoutSettings.cardScaleRange,
|
||||
step: 0.1
|
||||
)
|
||||
.accessibilityLabel(Text("appearance.cardScale"))
|
||||
} footer: {
|
||||
Text("appearance.cardScaleExplanation")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
Section {
|
||||
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
||||
|
||||
if showsCustomField {
|
||||
HStack {
|
||||
TextField("appearance.hexPlaceholder", text: $customHex)
|
||||
.haloPlainEntry()
|
||||
.onSubmit(applyCustomColor)
|
||||
|
||||
Button("action.apply", action: applyCustomColor)
|
||||
.disabled(RGB(string: customHex) == nil)
|
||||
}
|
||||
|
||||
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
||||
Text("appearance.invalidHex")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
}
|
||||
|
||||
if theme.accentWasAdjusted {
|
||||
// Ohne diesen Hinweis wirkt die Korrektur wie ein Fehler:
|
||||
// die gewählte Farbe sieht auf dem Bildschirm heller aus
|
||||
// als im Farbwähler, und niemand wüsste warum.
|
||||
Label {
|
||||
Text("appearance.adjusted \(theme.accent.hexString)")
|
||||
.font(.haloCaption)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("appearance.preview") {
|
||||
PreviewRow()
|
||||
}
|
||||
}
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("appearance.title"))
|
||||
.haloInlineTitle()
|
||||
.onAppear {
|
||||
if theme.selection.id == AccentOption.customIdentifier {
|
||||
customHex = theme.selection.color.hexString
|
||||
showsCustomField = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func swatch(_ option: AccentOption) -> some View {
|
||||
Button {
|
||||
theme.select(option)
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(option.color.swiftUIColor)
|
||||
.frame(height: 44)
|
||||
.overlay {
|
||||
if theme.selection == option {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.headline)
|
||||
// Dunkel auf der Farbe, nicht weiß: die Palette
|
||||
// enthält helle Töne wie Amber, auf denen Weiß
|
||||
// verschwinden würde.
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
theme.selection == option ? Color.haloTextPrimary : .clear,
|
||||
lineWidth: 2
|
||||
)
|
||||
.padding(-4)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(option.name))
|
||||
.accessibilityAddTraits(theme.selection == option ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
|
||||
private func applyCustomColor() {
|
||||
guard let color = RGB(string: customHex) else { return }
|
||||
theme.select(.custom(color))
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
||||
private struct PreviewRow: View {
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
Label("hero.play", systemImage: "play.fill")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
|
||||
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
WatchProgressBar(fraction: 0.45)
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||
///
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
||||
/// bekäme eine Sprache dazu, der andere nicht.
|
||||
///
|
||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||
/// kann. Ein Menü sieht überall gleich aus.
|
||||
struct AudioLanguagePicker: View {
|
||||
/// Für welche Ebene gewählt wird.
|
||||
enum Scope {
|
||||
case series(String)
|
||||
case library(String)
|
||||
}
|
||||
|
||||
let scope: Scope
|
||||
let title: LocalizedStringKey
|
||||
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Spacer(minLength: HaloSpacing.regular)
|
||||
|
||||
Menu {
|
||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||
// durchreichen.
|
||||
Button {
|
||||
set(nil)
|
||||
} label: {
|
||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
||||
Button {
|
||||
set(code)
|
||||
} label: {
|
||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.haloBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String? {
|
||||
switch scope {
|
||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
||||
}
|
||||
|
||||
private func set(_ code: String?) {
|
||||
switch scope {
|
||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func name(of code: String) -> String {
|
||||
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Was beim letzten Abspielversuch passiert ist.
|
||||
///
|
||||
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt
|
||||
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
||||
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
||||
struct DiagnosticsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("diagnostics.connection") {
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.haloCaption)
|
||||
}
|
||||
if let beschreibung = session.connectionDescription {
|
||||
LabeledContent("settings.connection", value: beschreibung)
|
||||
.font(.haloCaption)
|
||||
}
|
||||
} else {
|
||||
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if let attempt = diagnostics.lastAttempt {
|
||||
Section("diagnostics.lastAttempt") {
|
||||
LabeledContent("diagnostics.title.label", value: attempt.title)
|
||||
LabeledContent("diagnostics.method", value: attempt.method)
|
||||
LabeledContent("diagnostics.connectionUsed", value: attempt.connection)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("diagnostics.source")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Text(attempt.sourceDescription)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("diagnostics.steps") {
|
||||
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
||||
Text(step)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
HaloClipboard.copy(report(for: attempt))
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
} footer: {
|
||||
Text("diagnostics.copyHint")
|
||||
}
|
||||
} else {
|
||||
Section("diagnostics.lastAttempt") {
|
||||
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("diagnostics.title"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Der Bericht als Text zum Weitergeben.
|
||||
///
|
||||
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
|
||||
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
||||
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
||||
"""
|
||||
Halo \(Branding.version) · Build \(Branding.buildStamp)
|
||||
Verbindung: \(attempt.connection)
|
||||
Titel: \(attempt.title)
|
||||
Weg: \(attempt.method)
|
||||
Quelle: \(attempt.sourceDescription)
|
||||
|
||||
\(attempt.steps.joined(separator: "\n"))
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Was beim Herunterladen gilt.
|
||||
///
|
||||
/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit
|
||||
/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man bewusst
|
||||
/// trifft — beim Löschen von Filmen soll niemand versehentlich auf „sofort"
|
||||
/// rutschen.
|
||||
struct DownloadSettingsView: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "arrow.down.circle",
|
||||
colour: SettingsPalette.downloads,
|
||||
title: "sidebar.downloads",
|
||||
explanation: "settings.downloadsHeaderExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||
|
||||
Picker("settings.storageLimit", selection: storageLimitBinding) {
|
||||
Text("settings.storageUnlimited").tag(Int64(0))
|
||||
ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in
|
||||
Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("settings.downloadsExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Stepper(value: $downloads.maximumConcurrentDownloads, in: 1...5) {
|
||||
LabeledContent(
|
||||
"settings.concurrent",
|
||||
value: downloads.maximumConcurrentDownloads.formatted()
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("settings.concurrentHeader")
|
||||
} footer: {
|
||||
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu
|
||||
// laden teilt die Bandbreite so auf, dass keine Folge fertig
|
||||
// wird — und unterwegs ist eine fertige Folge mehr wert als
|
||||
// zehn halbe.
|
||||
Text("settings.concurrentExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.deleteWatched", selection: deleteAfterBinding) {
|
||||
Text("settings.deleteNever").tag(Double(-1))
|
||||
Text("settings.deleteImmediately").tag(Double(0))
|
||||
Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds)
|
||||
Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds)
|
||||
}
|
||||
|
||||
Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) {
|
||||
Text("settings.keepOff").tag(0)
|
||||
ForEach([1, 2, 3, 5], id: \.self) { count in
|
||||
Text("settings.keepCount \(count)").tag(count)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.downloads"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
private var storageLimitBinding: Binding<Int64> {
|
||||
Binding(
|
||||
get: { downloads.storageLimitBytes ?? 0 },
|
||||
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
|
||||
)
|
||||
}
|
||||
|
||||
private var deleteAfterBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
|
||||
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
|
||||
)
|
||||
}
|
||||
|
||||
private var keepUnwatchedBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { downloads.rules.keepUnwatchedEpisodes ?? 0 },
|
||||
set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||
struct HomeLayoutView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
|
||||
@State private var rows: [HomeRow] = []
|
||||
/// Welche Mediatheken überhaupt eine Reihe auf der Startseite stellen.
|
||||
///
|
||||
/// **Gemessen, nicht angenommen.** Nur Reihen, die der Server einer
|
||||
/// Mediathek zuordnet, kann der Aufmacher auseinanderhalten — bei
|
||||
/// Jellyfin sind das die „Neu in …"-Reihen. Gibt es für eine angehakte
|
||||
/// Mediathek keine, greift die Auswahl ins Leere, und das sieht aus wie
|
||||
/// ein kaputter Schalter.
|
||||
@State private var libraryBackedRows: Set<String> = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
list
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("homeLayout.title"))
|
||||
.haloInlineTitle()
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
@Bindable var layout = layout
|
||||
|
||||
Section {
|
||||
Picker("home.heroSource", selection: $layout.heroSource) {
|
||||
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
|
||||
Text(source.label).tag(source)
|
||||
}
|
||||
}
|
||||
Picker("home.heroCount", selection: $layout.heroCount) {
|
||||
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
|
||||
Text(count.formatted()).tag(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Regler und keine Auswahl aus festen Stufen: es gibt hier
|
||||
// keinen richtigen Wert, nur den, bei dem die erste Reihe
|
||||
// gerade so weit hereinlugt, wie man es mag.
|
||||
LabeledContent {
|
||||
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("home.heroHeight")
|
||||
}
|
||||
Slider(
|
||||
value: $layout.heroHeight,
|
||||
in: HomeLayoutSettings.heroHeightRange,
|
||||
step: 0.05
|
||||
)
|
||||
.accessibilityLabel(Text("home.heroHeight"))
|
||||
} header: {
|
||||
Text("home.hero")
|
||||
} footer: {
|
||||
Text("home.heroExplanation")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
||||
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
||||
// den man versehentlich trifft. Hier sind Haken: keiner gesetzt
|
||||
// ist der Normalfall und bedeutet, dass alles infrage kommt.
|
||||
if !session.libraries.isEmpty {
|
||||
Section {
|
||||
ForEach(session.libraries) { library in
|
||||
Button {
|
||||
toggleHeroLibrary(library.id)
|
||||
} label: {
|
||||
LabeledContent {
|
||||
if layout.heroLibraries.contains(library.id) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
} label: {
|
||||
Text(library.title)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
// Damit der ganze Streifen trifft und nicht nur
|
||||
// die Schrift — eine Zeile, bei der nur ein Wort
|
||||
// reagiert, liest sich als kaputt.
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(
|
||||
layout.heroLibraries.contains(library.id) ? .isSelected : []
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("home.heroLibraries")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Text("home.heroLibrariesExplanation")
|
||||
// Lieber eine Erklärung als ein Aufmacher, der
|
||||
// heimlich etwas anderes zeigt: trifft die Auswahl
|
||||
// keine einzige Reihe, gilt sie als wirkungslos — und
|
||||
// dann muss dastehen, warum.
|
||||
if greiftInsLeere {
|
||||
Text("home.heroLibrariesNoMatch")
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if rows.isEmpty {
|
||||
Text("homeLayout.empty")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach($rows) { $row in
|
||||
Toggle(isOn: Binding(
|
||||
get: { !row.isHidden },
|
||||
set: {
|
||||
row.isHidden = !$0
|
||||
layout.setHidden(!$0, for: row.id)
|
||||
}
|
||||
)) {
|
||||
Text(row.title)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
.onMove { indices, destination in
|
||||
rows.move(fromOffsets: indices, toOffset: destination)
|
||||
layout.setOrder(rows.map(\.id))
|
||||
}
|
||||
} header: {
|
||||
// **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste
|
||||
// stand er über allem — über dem Aufmacher, über den
|
||||
// Mediatheken — und tat dort nichts. Ein Knopf, der nur für
|
||||
// einen Teil des Bildschirms gilt, gehört an diesen Teil.
|
||||
//
|
||||
// Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne
|
||||
// vorherigen Moduswechsel.
|
||||
HStack {
|
||||
Text("home.rowsHeader")
|
||||
#if os(iOS)
|
||||
if !rows.isEmpty {
|
||||
Spacer()
|
||||
EditButton()
|
||||
.font(.haloCaption)
|
||||
.textCase(nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} footer: {
|
||||
Text("homeLayout.explanation")
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
/// Ob die Auswahl keine einzige Reihe trifft.
|
||||
private var greiftInsLeere: Bool {
|
||||
!layout.heroLibraries.isEmpty
|
||||
&& !libraryBackedRows.isEmpty
|
||||
&& layout.heroLibraries.isDisjoint(with: libraryBackedRows)
|
||||
}
|
||||
|
||||
/// Kreuzt eine Mediathek für den Aufmacher an oder ab.
|
||||
private func toggleHeroLibrary(_ identifier: String) {
|
||||
if layout.heroLibraries.contains(identifier) {
|
||||
layout.heroLibraries.remove(identifier)
|
||||
} else {
|
||||
layout.heroLibraries.insert(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wirft gespeicherte Kennungen weg, die es nicht mehr gibt.
|
||||
///
|
||||
/// **Sonst wirken sie unsichtbar weiter.** Nach einem Serverwechsel
|
||||
/// bleibt in den Voreinstellungen eine Mediathekskennung stehen, die zu
|
||||
/// nichts mehr passt — sie steht in keiner Zeile, lässt sich also nicht
|
||||
/// abwählen, und schränkt den Aufmacher trotzdem ein.
|
||||
///
|
||||
/// Nur, wenn überhaupt Mediatheken geladen sind: eine leere Liste heisst
|
||||
/// „noch nicht da", nicht „gibt es nicht".
|
||||
private func pruneHeroLibraries() {
|
||||
guard !session.libraries.isEmpty else { return }
|
||||
let vorhanden = Set(session.libraries.map(\.id))
|
||||
let bereinigt = layout.heroLibraries.intersection(vorhanden)
|
||||
guard bereinigt != layout.heroLibraries else { return }
|
||||
layout.heroLibraries = bereinigt
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
defer { isLoading = false }
|
||||
pruneHeroLibraries()
|
||||
guard let backend = session.backend else { return }
|
||||
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
|
||||
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
|
||||
// Mediatheken und Sammlungen angelegt sind.
|
||||
let hubs = (try? await backend.homeRows()) ?? []
|
||||
rows = layout.rows(from: hubs)
|
||||
libraryBackedRows = Set(hubs.compactMap(\.libraryID))
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeLayoutSettings.HeroSource {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .continueFirst: "home.heroSource.continueFirst"
|
||||
case .latestOnly: "home.heroSource.latestOnly"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Welche Sprachen der Player von sich aus wählen soll.
|
||||
///
|
||||
/// Die Einstellung gab es schon — der Player liest sie bei jeder Wiedergabe
|
||||
/// aus und wählt die passende Spur. Nur einstellen konnte man sie nirgends:
|
||||
/// sie stand in den Voreinstellungen und blieb dort für immer leer. Eine
|
||||
/// Vorliebe, die niemand äußern kann, ist keine.
|
||||
struct LanguagesView: View {
|
||||
@Environment(LanguagePreferences.self) private var settings
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var isAdding = false
|
||||
|
||||
private var codes: [String] { LanguagePreferences.offeredCodes }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "captions.bubble",
|
||||
colour: SettingsPalette.languages,
|
||||
title: "languages.title",
|
||||
explanation: "languages.headerExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("languages.audio", selection: $settings.audioLanguage) {
|
||||
Text("languages.serverDefault").tag("")
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(name(of: code)).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("languages.audioHeader")
|
||||
} footer: {
|
||||
Text("languages.audioExplanation")
|
||||
}
|
||||
|
||||
fallbackSection
|
||||
|
||||
Section {
|
||||
Toggle("languages.avoidAudioDescription", isOn: $settings.avoidsAudioDescription)
|
||||
} footer: {
|
||||
Text("languages.avoidAudioDescriptionExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("languages.subtitles", selection: $settings.subtitleLanguage) {
|
||||
Text("languages.subtitlesOff").tag("")
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(name(of: code)).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("languages.subtitlesHeader")
|
||||
} footer: {
|
||||
Text("languages.subtitlesExplanation")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("languages.title"))
|
||||
.haloInlineTitle()
|
||||
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
||||
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
||||
// genügt, sobald die Liste bearbeitbar ist.
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
|
||||
/// Die Rangliste.
|
||||
///
|
||||
/// **Was sie ist:** der Boden unter der Wunschsprache. Sie greift nur,
|
||||
/// wenn ein Titel die gewünschte Sprache gar nicht hat.
|
||||
///
|
||||
/// **Was sie nicht ist:** eine zweite Wunschsprache. Steht die
|
||||
/// Wunschsprache zur Verfügung, wird die Liste nicht einmal angesehen.
|
||||
@ViewBuilder
|
||||
private var fallbackSection: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Section {
|
||||
if settings.fallbacks.isEmpty {
|
||||
Text("languages.fallbackEmpty")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else {
|
||||
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
||||
// ist — eine Liste ohne sichtbaren Rang liest sich
|
||||
// wie eine Menge.
|
||||
Text("\(position + 1)")
|
||||
.font(.haloCaption.monospacedDigit())
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.frame(minWidth: 18, alignment: .trailing)
|
||||
Text(name(of: code))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
}
|
||||
.onMove { von, nach in
|
||||
settings.fallbacks.move(fromOffsets: von, toOffset: nach)
|
||||
}
|
||||
.onDelete { settings.fallbacks.remove(atOffsets: $0) }
|
||||
}
|
||||
|
||||
Button {
|
||||
isAdding = true
|
||||
} label: {
|
||||
Label("languages.fallbackAdd", systemImage: "plus")
|
||||
}
|
||||
.disabled(remaining.isEmpty)
|
||||
} header: {
|
||||
Text("languages.fallbackHeader")
|
||||
} footer: {
|
||||
Text("languages.fallbackExplanation")
|
||||
}
|
||||
.confirmationDialog("languages.fallbackAdd", isPresented: $isAdding, titleVisibility: .visible) {
|
||||
ForEach(remaining, id: \.self) { code in
|
||||
Button(name(of: code)) { settings.fallbacks.append(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was noch nicht in der Liste steht.
|
||||
///
|
||||
/// Eine Sprache zweimal in einer Rangliste hat keine Bedeutung — und die
|
||||
/// Liste benutzt den Code als Kennung, was bei Dubletten bräche.
|
||||
private var remaining: [String] {
|
||||
codes.filter { !settings.fallbacks.contains($0) }
|
||||
}
|
||||
|
||||
/// Der Name der Sprache in der Sprache des Geräts.
|
||||
private func name(of code: String) -> String {
|
||||
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
|
||||
/// Alles, was die Wiedergabe betrifft.
|
||||
///
|
||||
/// Vorher stand das verstreut in der langen Liste der Einstellungen, zwischen
|
||||
/// Serverangaben und Downloads. Zusammen ergibt es einen Bildschirm, den man
|
||||
/// einmal durchgeht und dann in Ruhe lässt.
|
||||
struct PlaybackSettingsView: View {
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "play.rectangle",
|
||||
colour: SettingsPalette.playback,
|
||||
title: "settings.playback",
|
||||
explanation: "settings.playbackExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("settings.qualityWifi", selection: $settings.wifiQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
Picker("settings.qualityCellular", selection: $settings.cellularQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.quality")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Text("settings.qualityExplanation")
|
||||
|
||||
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
||||
// liest nach dem zweiten Mal niemand mehr. Sie erscheint,
|
||||
// wenn „ohne Grenze" eingestellt ist **und** die letzte
|
||||
// Wiedergabe tatsächlich gehakt hat.
|
||||
//
|
||||
// Ohne Grenze kommt die Entscheidung oft bei direkter
|
||||
// Wiedergabe heraus — und dann gibt es keine Stufe, auf
|
||||
// die irgendetwas ausweichen könnte. Die Voreinstellung
|
||||
// bleibt trotzdem, wie sie ist: zuhause am Kabel ist sie
|
||||
// die richtige, und still umstellen wäre nicht in Ordnung.
|
||||
if settings.wifiQuality == .original, settings.lastPlaybackStruggled {
|
||||
Label {
|
||||
Text("settings.qualityUnlimitedHint")
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
}
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.rememberQuality", isOn: $settings.remembersQuality)
|
||||
Toggle("settings.showBitrate", isOn: $settings.showsBitrate)
|
||||
} footer: {
|
||||
Text("settings.rememberQualityExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.rewind", selection: $settings.rewindSeconds) {
|
||||
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||
Text("settings.seconds \(seconds)").tag(seconds)
|
||||
}
|
||||
}
|
||||
Picker("settings.forward", selection: $settings.forwardSeconds) {
|
||||
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||
Text("settings.seconds \(seconds)").tag(seconds)
|
||||
}
|
||||
}
|
||||
Picker("settings.defaultRate", selection: $settings.defaultRate) {
|
||||
ForEach(PlaybackSettings.rateChoices, id: \.self) { rate in
|
||||
Text(rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.controls")
|
||||
} footer: {
|
||||
// Zurück und vor getrennt, weil sie verschiedene Zwecke haben:
|
||||
// zurück, weil man etwas nicht verstanden hat; vor, weil man
|
||||
// etwas überspringen will.
|
||||
Text("settings.controlsExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.lockLandscape", isOn: $settings.locksLandscape)
|
||||
} footer: {
|
||||
Text("settings.lockLandscapeExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("settings.playback"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
private func rateLabel(_ rate: Double) -> String {
|
||||
rate == 1
|
||||
? String(localized: "settings.rateNormal")
|
||||
: rate.formatted(.number.precision(.fractionLength(0...2))) + "×"
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybackSettings.Quality {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .high: "quality.high"
|
||||
case .medium: "quality.medium"
|
||||
case .low: "quality.low"
|
||||
case .minimal: "quality.minimal"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||
struct ServerSettingsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
|
||||
// und damit die erste Frage bei jeder Rückmeldung über
|
||||
// Ruckeln.
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.connection", value: connectionLabel)
|
||||
}
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.haloCaption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let accountName = session.accountName {
|
||||
LabeledContent("settings.account", value: accountName)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(session.libraries) { library in
|
||||
LibraryRow(library: library)
|
||||
}
|
||||
} header: {
|
||||
Text("settings.libraries")
|
||||
} footer: {
|
||||
// Der Suchlauf stand einmal im Dreipunktmenü der Mediathek —
|
||||
// einen Tipp neben „Filter" und „Ansicht wechseln". Er
|
||||
// beschäftigt den Server minutenlang und gehört nicht in eine
|
||||
// Reihe mit Dingen, die man im Vorbeigehen antippt.
|
||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||
Text("settings.scanExplanation")
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("settings.server"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
||||
/// ist das die erste Frage.
|
||||
private var connectionLabel: String {
|
||||
session.isLocalConnection
|
||||
? String(localized: "settings.connectionLocal")
|
||||
: String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
||||
struct LibraryRow: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
private enum Phase { case idle, running, done }
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
||||
// „Filme" stand sonst als „Filme — Filme" da.
|
||||
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
||||
Text(library.kind.label)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||
Button {
|
||||
Task { await scan() }
|
||||
} label: {
|
||||
symbol
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(phase == .running)
|
||||
.accessibilityLabel(Text("library.scan"))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(library.title)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var symbol: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
case .running:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stößt einen Suchlauf auf dem Server an.
|
||||
///
|
||||
/// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein
|
||||
/// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen
|
||||
/// ist — ein Balken, der etwas anderes behauptet, wäre erfunden.
|
||||
private func scan() async {
|
||||
guard let backend = session.backend, phase != .running else { return }
|
||||
phase = .running
|
||||
try? await backend.refreshLibrary(library)
|
||||
phase = .done
|
||||
try? await Task.sleep(for: .seconds(4))
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibrary.Kind {
|
||||
/// Wie diese Art von Mediathek heißt.
|
||||
///
|
||||
/// Übersetzt statt durchgereicht: die Server liefern „movies" und
|
||||
/// „tvshows", und das großgeschrieben anzuzeigen war eine englische
|
||||
/// Beschriftung in einer deutschen Oberfläche.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .movie: String(localized: "library.kind.movie")
|
||||
case .show: String(localized: "library.kind.show")
|
||||
case .music: String(localized: "library.kind.music")
|
||||
case .photo: String(localized: "library.kind.photo")
|
||||
case .liveTV: String(localized: "library.kind.liveTV")
|
||||
case .other: String(localized: "library.kind.other")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Das farbige Symbol vor einer Einstellungszeile.
|
||||
///
|
||||
/// Nicht Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das
|
||||
/// Auge nichts wieder. Eine Farbe je Bereich macht „das mit dem blauen Zahnrad"
|
||||
/// zu einer brauchbaren Beschreibung — und genau so sucht man Einstellungen,
|
||||
/// die man schon einmal gesehen hat.
|
||||
///
|
||||
/// Die Farben sind fest und **nicht** die Akzentfarbe: wenn alles die gleiche
|
||||
/// Farbe trägt, unterscheidet sie nichts mehr.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kopfkarte eines Einstellungsbildschirms.
|
||||
///
|
||||
/// Sagt in einem Satz, worum es hier geht. Das klingt nach Zierde, ist aber
|
||||
/// der Unterschied zwischen „Wiedergabe" und „hier stelle ich ein, wie weit
|
||||
/// der Doppeltipp springt" — und erspart das Durchprobieren aller Schalter.
|
||||
struct SettingsHeader: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
let title: LocalizedStringKey
|
||||
let explanation: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
||||
|
||||
Text(title)
|
||||
.font(.haloTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text(explanation)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Farben der Bereiche, an einer Stelle.
|
||||
enum SettingsPalette {
|
||||
static let server = Color.gray
|
||||
static let appearance = Color.blue
|
||||
static let home = Color.green
|
||||
static let playback = Color.pink
|
||||
static let languages = Color.teal
|
||||
static let subtitles = Color.yellow
|
||||
static let downloads = Color.indigo
|
||||
static let diagnostics = Color.orange
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||
///
|
||||
/// Vorher stand alles untereinander auf einem Bildschirm: Serverangaben,
|
||||
/// Qualität, Wiedergabe, drei Verweise, Downloads, Version, Abmelden. Das
|
||||
/// war beim Bauen bequem und beim Suchen anstrengend — je mehr Einstellungen
|
||||
/// dazukamen, desto weiter rutschte alles nach unten.
|
||||
///
|
||||
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
||||
struct SettingsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if session.backend != nil {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ServerSettingsView()
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(session.serverName)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} label: {
|
||||
row(
|
||||
"settings.server",
|
||||
symbol: "externaldrive.connected.to.line.below",
|
||||
colour: SettingsPalette.server
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.look") {
|
||||
link("appearance.title", symbol: "paintpalette", colour: SettingsPalette.appearance) {
|
||||
AppearanceView()
|
||||
}
|
||||
link("homeLayout.title", symbol: "rectangle.stack", colour: SettingsPalette.home) {
|
||||
HomeLayoutView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.playing") {
|
||||
link("settings.playback", symbol: "play.rectangle", colour: SettingsPalette.playback) {
|
||||
PlaybackSettingsView()
|
||||
}
|
||||
link("languages.title", symbol: "captions.bubble", colour: SettingsPalette.languages) {
|
||||
LanguagesView()
|
||||
}
|
||||
link("subtitleStyle.title", symbol: "textformat.size", colour: SettingsPalette.subtitles) {
|
||||
SubtitleAppearanceView()
|
||||
}
|
||||
link("sidebar.downloads", symbol: "arrow.down.circle", colour: SettingsPalette.downloads) {
|
||||
DownloadSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
link("diagnostics.title", symbol: "stethoscope", colour: SettingsPalette.diagnostics) {
|
||||
DiagnosticsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("action.signOut", role: .destructive) {
|
||||
showsSignOutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.haloRevealsTopChrome()
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.settings"))
|
||||
.haloInlineTitle()
|
||||
.confirmationDialog(
|
||||
Text("settings.signOutConfirm"),
|
||||
isPresented: $showsSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.signOutExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
private func link<Destination: View>(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
colour: Color,
|
||||
@ViewBuilder destination: @escaping () -> Destination
|
||||
) -> some View {
|
||||
NavigationLink {
|
||||
destination()
|
||||
} label: {
|
||||
row(title, symbol: symbol, colour: colour)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
SettingsIcon(symbol: symbol, colour: colour)
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
|
||||
/// Wie Untertitel aussehen sollen.
|
||||
struct SubtitleAppearanceView: View {
|
||||
@Environment(SubtitleAppearance.self) private var appearance
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appearance = appearance
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "textformat.size",
|
||||
colour: SettingsPalette.subtitles,
|
||||
title: "subtitleStyle.title",
|
||||
explanation: "subtitleStyle.headerExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
preview
|
||||
} header: {
|
||||
Text("subtitleStyle.previewHeader")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("subtitleStyle.size", selection: $appearance.size) {
|
||||
ForEach(SubtitleStyle.Size.allCases) { size in
|
||||
Text(size.label).tag(size)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("subtitleStyle.sizeExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("subtitleStyle.palette", selection: $appearance.palette) {
|
||||
ForEach(SubtitleStyle.Palette.allCases) { palette in
|
||||
Text(palette.label).tag(palette)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("subtitleStyle.paletteHeader")
|
||||
} footer: {
|
||||
// Die Grenze gehört hierher, nicht nur in einen Kommentar:
|
||||
// bei eingebrannten Untertiteln tut jede dieser Einstellungen
|
||||
// sichtbar nichts, und ohne Erklärung sieht das nach einem
|
||||
// Fehler aus.
|
||||
Text("subtitleStyle.paletteExplanation")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("subtitleStyle.title"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Ein Beispielsatz über grauer Fläche.
|
||||
///
|
||||
/// **Eine Nachbildung, nicht AVFoundations Renderer.** Sie zeigt die
|
||||
/// Wirkung der Einstellung, nicht das Bild auf den Punkt genau — wer
|
||||
/// später zwei Pixel „repariert", repariert die falsche Sache.
|
||||
private var preview: some View {
|
||||
ZStack {
|
||||
// Mittelgrau: auf Weiss wäre weisse Schrift unsichtbar, auf
|
||||
// Schwarz schwarze. Ein Grauton zeigt beide ehrlich.
|
||||
LinearGradient(
|
||||
colors: [Color(white: 0.35), Color(white: 0.18)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
Text("subtitleStyle.previewText")
|
||||
.font(.system(size: 15 * CGFloat(appearance.size.rawValue) / 100, weight: .medium))
|
||||
.foregroundStyle(appearance.style.foreground.color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(appearance.style.background.color)
|
||||
.shadow(
|
||||
color: appearance.style.wantsEdgeShadow ? .black.opacity(0.9) : .clear,
|
||||
radius: 2,
|
||||
y: 1
|
||||
)
|
||||
.padding(HaloSpacing.regular)
|
||||
}
|
||||
.frame(height: 96)
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
extension SubtitleStyle.RGBA {
|
||||
var color: Color {
|
||||
Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Während ein Weg zum Server gesucht wird.
|
||||
struct ConnectingView: View {
|
||||
let serverName: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: HaloSpacing.loose) {
|
||||
ProgressView()
|
||||
Text(serverName.isEmpty
|
||||
? String(localized: "connecting.searching")
|
||||
: String(localized: "connecting.to \(serverName)"))
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.haloCanvas)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn etwas schiefgegangen ist.
|
||||
struct ConnectionFailureView: View {
|
||||
let message: String
|
||||
let onRetry: () -> Void
|
||||
let onSignOut: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
|
||||
Button("action.signOut", action: onSignOut)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import JellyfinKit
|
||||
|
||||
/// Die Anmeldung an einem Jellyfin-Server.
|
||||
///
|
||||
/// Drei Bildschirme in einem, weil es drei Fragen sind und nicht mehr: wo
|
||||
/// steht der Server, wie heißt du, und — wenn der Server es kann — bestätige
|
||||
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
||||
/// durch diese App läuft.
|
||||
struct JellyfinSignInView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var model: JellyfinSignInModel?
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private enum Field { case address, userName, password }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
if let model {
|
||||
content(for: model)
|
||||
.padding(.horizontal, HaloSpacing.section)
|
||||
.padding(.vertical, HaloSpacing.section)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("signIn.jellyfin.title"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("action.cancel") {
|
||||
model?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.task {
|
||||
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
||||
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
||||
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
||||
guard model == nil else { return }
|
||||
let fresh = JellyfinSignInModel(identity: session.jellyfinDeviceIdentity)
|
||||
fresh.onAuthenticated = { client, account, serverName in
|
||||
dismiss()
|
||||
Task { await session.adoptJellyfin(client: client, account: account, serverName: serverName) }
|
||||
}
|
||||
model = fresh
|
||||
}
|
||||
.onDisappear {
|
||||
// Wird das Sheet weggewischt, läuft sonst die Abfrageschleife
|
||||
// noch minutenlang weiter — unsichtbar und für nichts.
|
||||
model?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
||||
switch model.step {
|
||||
case .address:
|
||||
addressStep(model: model)
|
||||
case .checking:
|
||||
waiting(text: "signIn.jellyfin.searching")
|
||||
case .quickConnect(let code):
|
||||
quickConnectStep(model: model, code: code)
|
||||
case .credentials:
|
||||
credentialsStep(model: model)
|
||||
case .signingIn:
|
||||
waiting(text: "signIn.jellyfin.signingIn")
|
||||
}
|
||||
|
||||
if let message = model.errorMessage {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Adresse
|
||||
|
||||
@ViewBuilder
|
||||
private func addressStep(model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text("signIn.jellyfin.addressPrompt")
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
||||
.textFieldStyle(.plain)
|
||||
.textContentType(.URL)
|
||||
.haloPlainEntry(isAddress: true)
|
||||
.submitLabel(.go)
|
||||
.focused($focusedField, equals: .address)
|
||||
.onSubmit { Task { await model.findServer() } }
|
||||
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
|
||||
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
||||
.task { focusedField = .address }
|
||||
.padding(HaloSpacing.regular)
|
||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
||||
// niemandem etwas, „192.168.1.50" jedem.
|
||||
Text("signIn.jellyfin.addressHint")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
|
||||
Button {
|
||||
focusedField = nil
|
||||
Task { await model.findServer() }
|
||||
} label: {
|
||||
Text("signIn.jellyfin.connect")
|
||||
.font(.haloRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(model.address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Code
|
||||
|
||||
@ViewBuilder
|
||||
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
foundServerHeader(model: model)
|
||||
|
||||
Text("signIn.jellyfin.quickConnectHint")
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
|
||||
Text(code)
|
||||
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
||||
.tracking(8)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("signIn.jellyfin.waitingForApproval")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
Text("signIn.jellyfin.privacy")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
|
||||
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
||||
.font(.haloCaption)
|
||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Benutzername und Passwort
|
||||
|
||||
@ViewBuilder
|
||||
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
||||
@Bindable var model = model
|
||||
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
foundServerHeader(model: model)
|
||||
|
||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
||||
.textContentType(.username)
|
||||
.haloPlainEntry()
|
||||
.submitLabel(.next)
|
||||
.focused($focusedField, equals: .userName)
|
||||
.onSubmit { focusedField = .password }
|
||||
// **Tabulator weiterreichen, von Hand.**
|
||||
//
|
||||
// Auf dem Mac ist „Name, Tab, Passwort, Eingabe" der Weg, den
|
||||
// jeder nimmt, der einen Passwortspeicher benutzt. SwiftUI
|
||||
// schiebt den Fokus hier aber nicht selbst weiter: die Taste
|
||||
// kommt im Feld an und bleibt dort liegen.
|
||||
.onKeyPress(.tab) {
|
||||
focusedField = .password
|
||||
return .handled
|
||||
}
|
||||
.padding(HaloSpacing.regular)
|
||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||
|
||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
||||
.textContentType(.password)
|
||||
.submitLabel(.go)
|
||||
.focused($focusedField, equals: .password)
|
||||
.onSubmit { Task { await model.signInWithPassword() } }
|
||||
// Und zurück: sonst wäre der Weg eine Einbahnstraße, und ein
|
||||
// Vertipper im Namen hieße, zur Maus greifen.
|
||||
.onKeyPress(.tab) {
|
||||
focusedField = .userName
|
||||
return .handled
|
||||
}
|
||||
.padding(HaloSpacing.regular)
|
||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
||||
|
||||
Button {
|
||||
focusedField = nil
|
||||
Task { await model.signInWithPassword() }
|
||||
} label: {
|
||||
Text("signIn.jellyfin.submit")
|
||||
.font(.haloRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
.disabled(model.userName.isEmpty)
|
||||
|
||||
// Das Passwort wird für die eine Anfrage gehalten und danach
|
||||
// verworfen. Gespeichert wird nur der Token, und der im
|
||||
// Schlüsselbund.
|
||||
Text("signIn.jellyfin.passwordNote")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
|
||||
if model.offersQuickConnect {
|
||||
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
||||
.font(.haloCaption)
|
||||
}
|
||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
@ViewBuilder
|
||||
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
||||
Text("signIn.jellyfin.foundServer")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.textCase(.uppercase)
|
||||
Text(model.serverName ?? model.address)
|
||||
.font(.haloTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func waiting(text: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(text)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, HaloSpacing.section)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Der Anmeldebildschirm.
|
||||
///
|
||||
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
|
||||
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
|
||||
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
|
||||
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
|
||||
///
|
||||
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
|
||||
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
|
||||
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
|
||||
struct SignInView: View {
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@State private var showsJellyfinSignIn = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: HaloSpacing.section) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: HaloSpacing.regular) {
|
||||
Image(systemName: "play.rectangle.on.rectangle")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(accent)
|
||||
|
||||
Text(Branding.appName)
|
||||
.font(.haloHero)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text("signIn.tagline")
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: HaloSpacing.regular) {
|
||||
Button {
|
||||
showsJellyfinSignIn = true
|
||||
} label: {
|
||||
Text("signIn.withJellyfin")
|
||||
.font(.haloRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
Text("signIn.privacy")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer().frame(height: HaloSpacing.section)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.section)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.haloCanvas)
|
||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||
JellyfinSignInView()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user