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>
168 lines
6.3 KiB
Swift
168 lines
6.3 KiB
Swift
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
|
|
}
|
|
}
|