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