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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -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 = {}
}
+308
View File
@@ -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)
}
}
}
}
+71
View File
@@ -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)
}
}
}
}
+59
View File
@@ -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)
}
}
}
+90
View File
@@ -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 []
}
}
}
+297
View File
@@ -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)
}
}
}
+459
View File
@@ -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
}
}
}
+147
View File
@@ -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
}
}
+320
View File
@@ -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
}
}
+414
View File
@@ -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)
}
}
+358
View File
@@ -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
}
}
}
+99
View File
@@ -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: " · ")
}
}
+86
View File
@@ -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()
}
}
+81
View File
@@ -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)
}
}
}
+167
View File
@@ -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
}
}
+132
View File
@@ -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"
}
}
}
+419
View File
@@ -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")
}
}
+469
View File
@@ -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()
}
}
+392
View File
@@ -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()
}
}
+258
View File
@@ -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()
}
}
+107
View File
@@ -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 {}
+61
View File
@@ -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"))
}
}
+138
View File
@@ -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)
}
}
+49
View File
@@ -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 }
}
+641
View File
@@ -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 {}
+81
View File
@@ -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
+68
View File
@@ -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()
}
}
+50
View File
@@ -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
+38
View File
@@ -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)
}
}
+248
View File
@@ -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)
}
}
}
}
+119
View File
@@ -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
+209
View File
@@ -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)")
}
}
+152
View File
@@ -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)
}
}
+217
View File
@@ -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
+92
View File
@@ -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()
}
}
}
+170
View File
@@ -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()
}
}
+104
View File
@@ -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 }
)
}
}
+237
View File
@@ -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"
}
}
}
+140
View File
@@ -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")
}
}
}
+69
View File
@@ -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
}
+123
View File
@@ -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)
}
}
+279
View File
@@ -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)
}
}
+67
View File
@@ -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()
}
}
}