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
+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)
}
}