Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Der erste Buchstabe auf einer Scheibe.
|
||||
///
|
||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
||||
/// angemeldet ist.
|
||||
///
|
||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
||||
struct AccountAvatar: View {
|
||||
let name: String
|
||||
var diameter: CGFloat = 26
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
Text(name.prefix(1).uppercased())
|
||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
|
||||
// Fehler.
|
||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.background(accent, in: .circle)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Öffnet die Einstellungen.
|
||||
///
|
||||
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
|
||||
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
|
||||
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
|
||||
/// als Argument durchgereicht bekommen.
|
||||
@Entry var haloOpenSettings: () -> Void = {}
|
||||
}
|
||||
|
||||
/// Das Monogramm als Knopf für eine Werkzeugleiste.
|
||||
///
|
||||
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
|
||||
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
|
||||
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
|
||||
/// steht, kann nur der entscheiden, der die Reihe schreibt.
|
||||
struct AccountToolbarButton: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloOpenSettings) private var openSettings
|
||||
|
||||
var body: some View {
|
||||
Button(action: openSettings) {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("sidebar.settings"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Wer angemeldet ist — unten in der Seitenleiste.
|
||||
///
|
||||
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es in jeder
|
||||
/// App der letzte Eintrag ist. Und von dort führt der Weg in die
|
||||
/// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den
|
||||
/// Mediatheken stehen.
|
||||
///
|
||||
/// **Eine Karte, die über der Zeile aufgeht**, kein Systemmenü: mit denselben
|
||||
/// Abständen und derselben Schrift wie der Rest der App, statt der grauen
|
||||
/// Standardliste.
|
||||
///
|
||||
/// Sie hängt als Popover an der Zeile und wächst nicht in der Leiste selbst.
|
||||
/// Das war der erste Versuch, und er ging schief: die Leiste unten hat eine
|
||||
/// feste Höhe, die Karte wuchs nach unten aus dem Bild heraus, und die Zeile
|
||||
/// mit dem Namen verschwand dabei.
|
||||
///
|
||||
/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen
|
||||
/// ihren eigenen Eintrag.
|
||||
struct AccountBar: View {
|
||||
@Binding var selection: SidebarDestination
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var isExpanded = false
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
private var title: String {
|
||||
session.accountName ?? session.serverName
|
||||
}
|
||||
|
||||
/// Der Server steht nur dann darunter, wenn oben schon der Kontoname
|
||||
/// steht — sonst stünde zweimal dasselbe.
|
||||
private var subtitle: String? {
|
||||
session.accountName == nil ? nil : session.serverName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
accountRow
|
||||
.popover(isPresented: $isExpanded, arrowEdge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
action("account.settings", symbol: "gearshape") {
|
||||
isExpanded = false
|
||||
selection = .settings
|
||||
}
|
||||
separator
|
||||
action(
|
||||
"action.signOut",
|
||||
symbol: "rectangle.portrait.and.arrow.right",
|
||||
isDestructive: true
|
||||
) {
|
||||
isExpanded = false
|
||||
showsSignOutConfirmation = true
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 240)
|
||||
.background(Color.haloSurface)
|
||||
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
||||
// steht trotzdem da, damit aus dem Popover dort kein
|
||||
// bildschirmfüllendes Sheet wird.
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("settings.signOutConfirm"),
|
||||
isPresented: $showsSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.signOutExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
private var accountRow: some View {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
AccountAvatar(name: title)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
// Ohne Vorrang staucht SwiftUI den Text vor dem Zwischenraum:
|
||||
// in einer schmalen Leiste stand vom Namen nur noch „S…" da.
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Zeigt nach oben: dorthin geht die Karte auf.
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(title))
|
||||
.accessibilityHint(Text("account.hint"))
|
||||
}
|
||||
|
||||
private func action(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
isDestructive: Bool = false,
|
||||
perform: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: perform) {
|
||||
Label(title, systemImage: symbol)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Wo die Navigation sitzt.
|
||||
///
|
||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine
|
||||
/// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt.
|
||||
enum NavigationPlacement: String {
|
||||
case sidebar
|
||||
case top
|
||||
}
|
||||
|
||||
/// Ein Eintrag in der Navigation.
|
||||
struct NavigationEntry: Identifiable, Arrangeable {
|
||||
let destination: SidebarDestination
|
||||
let title: Text
|
||||
let symbol: String
|
||||
/// Der Titel als reiner Text — für die Sortierlogik und für die
|
||||
/// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen.
|
||||
let plainTitle: String
|
||||
|
||||
var id: SidebarDestination { destination }
|
||||
var arrangementID: String { destination.storageKey }
|
||||
var arrangementTitle: String { plainTitle }
|
||||
}
|
||||
|
||||
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
|
||||
struct NavigationGroup: Identifiable {
|
||||
let id: String
|
||||
let title: LocalizedStringKey?
|
||||
let entries: [NavigationEntry]
|
||||
}
|
||||
|
||||
// MARK: - Seitenleiste
|
||||
|
||||
/// Die Seitenleiste, wie sie auf dem iPad aussieht — überall.
|
||||
///
|
||||
/// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den
|
||||
/// Fensterrand setzt und sich das nicht umstellen lässt. Siehe
|
||||
/// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das
|
||||
/// Kleid ist unseres.
|
||||
///
|
||||
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
|
||||
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
|
||||
///
|
||||
/// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich.
|
||||
/// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche
|
||||
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
||||
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
||||
/// „umrandet" und „aus Glas".
|
||||
struct HaloSidebar: View {
|
||||
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
||||
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@Binding var placement: NavigationPlacement
|
||||
let layout: NavigationLayoutSettings
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
||||
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
||||
@State private var collapsed: Set<String> = []
|
||||
@State private var isEditing = false
|
||||
|
||||
/// Die Fläche, auf der die Leiste liegt.
|
||||
///
|
||||
/// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner
|
||||
/// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein
|
||||
/// heller Balken von mehreren Punkten Breite, der neben der Leiste stand —
|
||||
/// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts
|
||||
/// zu erkennen.
|
||||
///
|
||||
/// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche
|
||||
/// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen
|
||||
/// wird; und ein Haarstrich, der oben links aufhellt und unten rechts
|
||||
/// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was
|
||||
/// schwebt, wirft einen.
|
||||
@ViewBuilder
|
||||
static func surface(in shape: some InsettableShape) -> some View {
|
||||
shape
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
|
||||
.overlay {
|
||||
shape.strokeBorder(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .white.opacity(0.30), location: 0),
|
||||
.init(color: .white.opacity(0.05), location: 0.5),
|
||||
.init(color: .white.opacity(0.16), location: 1),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static let width: CGFloat = 235
|
||||
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
|
||||
static let margin: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
|
||||
// **Doch mit Bildlaufansicht.** Hier stand „die Einträge sind
|
||||
// wenige und passen" — und das stimmte auf einem iPad und im
|
||||
// Hochformat. Auf einem grossen iPhone **quer** ist der
|
||||
// Bildschirm vierhundertvierzig Punkte hoch, und die Leiste
|
||||
// braucht mit Kopf, Gruppen, Trenner und Kontozeile gut
|
||||
// fünfhundertdreissig.
|
||||
//
|
||||
// Eine `VStack` schrumpft dann nicht, sie **überragt**: der
|
||||
// `HStack` daneben nimmt das Maximum, und der ganze Wurzelbaum
|
||||
// wurde hundertachtunddreissig Punkte höher als der Bildschirm.
|
||||
// Was darin als Überlagerung liegt — der Player — ging oben und
|
||||
// unten hinaus, und Kopfzeile wie Zeitangaben waren weg. Der
|
||||
// Fehler sass also in der Navigation und zeigte sich im Player.
|
||||
//
|
||||
// `basedOnSize`: es wird nur gescrollt, wenn es nötig ist. Wo die
|
||||
// Einträge passen, sieht man keinen Unterschied.
|
||||
//
|
||||
// Nachgerechnet, damit es nicht wiederkommt: scrollbarer Teil
|
||||
// 421 Punkte (acht Zeilen à 36,33, eine Gruppenüberschrift
|
||||
// 22,33, acht Zwischenräume à 12, unten 12), starrer Rest
|
||||
// 137,6 (Kopfzeile 48,33, Trenner 1, Kontozeile 64,27, aussen
|
||||
// zweimal 12). Zusammen 558,6 — gemessen wurden 558,67. Nach dem
|
||||
// Umbau bleiben 137,6 starr, bei 420 verfügbaren: auch mit
|
||||
// doppelt so vielen Mediatheken passt das.
|
||||
//
|
||||
// Beim Bearbeiten braucht es eine Liste — nur die bringt das
|
||||
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
||||
if isEditing {
|
||||
Text("sidebar.editHint")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.bottom, HaloSpacing.snug)
|
||||
|
||||
List {
|
||||
ForEach(groups) { group in
|
||||
Section {
|
||||
ForEach(editable(group)) { entry in
|
||||
editRow(entry)
|
||||
}
|
||||
.onMove { move(in: group, from: $0, to: $1) }
|
||||
} header: {
|
||||
if let title = group.title {
|
||||
Text(title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
ForEach(groups) { group in
|
||||
section(visible(group))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.bottom, HaloSpacing.regular)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
AccountBar(selection: $selection)
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.frame(width: Self.width)
|
||||
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
||||
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
||||
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
||||
// und Leiste. Der schwebende Eindruck kommt von den übrigen drei
|
||||
// Seiten und vom Schatten.
|
||||
.padding(.leading, Self.margin)
|
||||
.padding(.vertical, Self.margin)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
||||
} label: {
|
||||
Text(isEditing ? "action.done" : "sidebar.edit")
|
||||
.font(.haloCaption)
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.tight)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if !isEditing {
|
||||
Button {
|
||||
placement = .top
|
||||
} label: {
|
||||
Image(systemName: "rectangle.topthird.inset.filled")
|
||||
.font(.haloRowHeader)
|
||||
.padding(HaloSpacing.tight)
|
||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
||||
// wie das Zeichen selbst — auf dem Mac trifft man das
|
||||
// mit dem Zeiger kaum.
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToTop"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.top, HaloSpacing.regular)
|
||||
.padding(.bottom, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
||||
/// Reihenfolge, auch die ausgeblendeten.
|
||||
private func editable(_ group: NavigationGroup) -> [NavigationEntry] {
|
||||
layout.ordered(group.entries)
|
||||
}
|
||||
|
||||
/// Verschiebt einen Eintrag innerhalb seiner Gruppe.
|
||||
///
|
||||
/// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander.
|
||||
/// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine
|
||||
/// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe
|
||||
/// wieder eine Sonderregel zu haben.
|
||||
private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) {
|
||||
var entries = editable(group)
|
||||
entries.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var order: [String] = []
|
||||
for other in groups {
|
||||
let ids = other.id == group.id
|
||||
? entries.map(\.arrangementID)
|
||||
: editable(other).map(\.arrangementID)
|
||||
order.append(contentsOf: ids)
|
||||
}
|
||||
layout.setOrder(order)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section(_ group: NavigationGroup) -> some View {
|
||||
if let title = group.title {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.25)) {
|
||||
if collapsed.contains(group.id) {
|
||||
collapsed.remove(group.id)
|
||||
} else {
|
||||
collapsed.insert(group.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if !collapsed.contains(group.id) {
|
||||
ForEach(group.entries) { entry in
|
||||
row(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus,
|
||||
/// Reihenfolge angewandt.
|
||||
private func visible(_ group: NavigationGroup) -> NavigationGroup {
|
||||
NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries))
|
||||
}
|
||||
|
||||
private func editRow(_ entry: NavigationEntry) -> some View {
|
||||
let isHidden = layout.isHidden(entry.arrangementID)
|
||||
|
||||
return HStack(spacing: HaloSpacing.snug) {
|
||||
Button {
|
||||
layout.setHidden(!isHidden, for: entry.arrangementID)
|
||||
} label: {
|
||||
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
||||
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide"))
|
||||
|
||||
Image(systemName: entry.symbol)
|
||||
.frame(width: 22)
|
||||
entry.title
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
|
||||
}
|
||||
|
||||
private func row(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
Image(systemName: entry.symbol)
|
||||
.font(.haloBody)
|
||||
.frame(width: 22, alignment: .center)
|
||||
entry.title
|
||||
.font(.haloBody)
|
||||
// Ein langer Mediathekenname darf die Leiste nicht
|
||||
// breiter machen; sie hat eine feste Breite.
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if isSelected {
|
||||
Capsule().fill(Color.haloElevated)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leiste oben
|
||||
|
||||
/// Dieselben Einträge als schwebende Leiste oben.
|
||||
///
|
||||
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
||||
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
||||
/// hinauswächst, wäre schlechter als ein Menü.
|
||||
struct HaloTopBar: View {
|
||||
let groups: [NavigationGroup]
|
||||
@Binding var selection: SidebarDestination
|
||||
@Binding var placement: NavigationPlacement
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
Button {
|
||||
placement = .sidebar
|
||||
} label: {
|
||||
Image(systemName: "sidebar.leading")
|
||||
.font(.haloBody)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
||||
// das Zeichen; auf dem Mac war der Umschalter damit
|
||||
// praktisch nicht zu treffen.
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.accessibilityLabel(Text("sidebar.moveToSide"))
|
||||
|
||||
ForEach(groups) { group in
|
||||
if group.title == nil {
|
||||
ForEach(group.entries) { entry in
|
||||
pill(entry)
|
||||
}
|
||||
} else {
|
||||
menu(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background { HaloSidebar.surface(in: .capsule) }
|
||||
.clipShape(.capsule)
|
||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
}
|
||||
|
||||
private func pill(_ entry: NavigationEntry) -> some View {
|
||||
let isSelected = selection == entry.destination
|
||||
|
||||
return Button {
|
||||
selection = entry.destination
|
||||
} label: {
|
||||
entry.title
|
||||
.font(.haloBody)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if isSelected { Capsule().fill(Color.haloElevated) }
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func menu(_ group: NavigationGroup) -> some View {
|
||||
let containsSelection = group.entries.contains { $0.destination == selection }
|
||||
|
||||
return Menu {
|
||||
ForEach(group.entries) { entry in
|
||||
Button { selection = entry.destination } label: {
|
||||
Label { entry.title } icon: { Image(systemName: entry.symbol) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
if let title = group.title {
|
||||
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
||||
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
||||
// Grund, an einem Wort zu sparen.
|
||||
Text(title).fixedSize()
|
||||
}
|
||||
Image(systemName: "chevron.down").font(.haloCaption)
|
||||
}
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background {
|
||||
if containsSelection { Capsule().fill(Color.haloElevated) }
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import MediaKit
|
||||
|
||||
/// Ein Navigationsstapel mit allen Zielen — und einem Weg, von innen zu
|
||||
/// schieben.
|
||||
///
|
||||
/// **Warum der Pfad hier liegt und nicht beim System.** Ein `NavigationLink`
|
||||
/// schiebt selbst; wer stattdessen aus einem Knopf heraus schieben will,
|
||||
/// braucht eine Bindung. Genau das verlangt die Kachel, seit Antippen
|
||||
/// abspielt: der Weg auf die Detailseite führt dann über das Kontextmenü, und
|
||||
/// ein Menüeintrag ist kein Link.
|
||||
///
|
||||
/// **Die Ziele stehen hier und nur hier.** Sie je Bildschirm anzumelden hat
|
||||
/// schon einmal nicht funktioniert: liegen zwei Bildschirme übereinander,
|
||||
/// entscheidet sonst die Reihenfolge, und ein Tippen landete im Nichts.
|
||||
struct MediaStack<Content: View>: View {
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
content()
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
.navigationDestination(for: LibraryRoute.self) { route in
|
||||
if let library = session.libraries.first(where: { $0.id == route.id }) {
|
||||
LibraryView(library: library)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
||||
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
||||
}
|
||||
.environment(\.haloPush) { ziel in path.append(ziel) }
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Schiebt ein Ziel auf den Stapel, in dem man gerade steht.
|
||||
///
|
||||
/// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt
|
||||
/// in einer Reihe, die in der Startseite steckt, die im Stapel steckt.
|
||||
/// Eine Bindung durch jede Ebene zu reichen hiesse, drei Ansichten um ein
|
||||
/// Argument zu erweitern, das sie nur weitergeben.
|
||||
///
|
||||
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
||||
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
||||
@Entry var haloPush: (any Hashable) -> Void = { _ in }
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
import HaloDownloads
|
||||
import HaloPlayback
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
///
|
||||
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
|
||||
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
||||
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
||||
struct RootView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(HaloTheme.self) private var theme
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(QuickPlay.self) private var quickPlay
|
||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
|
||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||
///
|
||||
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
|
||||
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
|
||||
/// genau das ist vorher passiert.
|
||||
@State private var player: PlayerModel?
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@State private var selection: SidebarDestination = .home
|
||||
/// Was der Nutzer an der Navigation umgestellt hat.
|
||||
///
|
||||
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
||||
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
||||
/// zum Aufbewahren.
|
||||
@AppStorage("halo.navigation.customization")
|
||||
private var customization = TabViewCustomization()
|
||||
|
||||
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
||||
///
|
||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
||||
@AppStorage("halo.navigation.placement")
|
||||
private var storedPlacement: NavigationPlacement = .sidebar
|
||||
|
||||
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
||||
///
|
||||
/// Der Umweg ist nötig, damit das Umschalten überhaupt zu sehen ist:
|
||||
/// `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung
|
||||
/// erst danach zurück. Sie kommt also außerhalb der Animation an, und die
|
||||
/// Leiste sprang von einer Stellung in die andere, statt zu wandern.
|
||||
///
|
||||
/// Hier ändert sich der Zustand sofort und im selben Zug; gespeichert wird
|
||||
/// hinterher.
|
||||
@State private var placement: NavigationPlacement = .sidebar
|
||||
|
||||
/// Wie die Leiste umzieht. Kurz, aber sichtbar.
|
||||
private static let placementAnimation: Animation = .snappy(duration: 0.32)
|
||||
|
||||
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
|
||||
/// überhaupt dastehen soll.
|
||||
@State private var navigationLayout = NavigationLayoutSettings()
|
||||
|
||||
/// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist.
|
||||
@State private var topChrome = TopChrome()
|
||||
|
||||
/// Wie hoch die Statusleiste baut.
|
||||
///
|
||||
/// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte,
|
||||
/// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des
|
||||
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
|
||||
/// wäre er längst null.
|
||||
@State private var statusBarHeight: CGFloat = 0
|
||||
|
||||
/// Ob die Einstellungen gerade offen sind.
|
||||
///
|
||||
/// Nur auf dem iPhone: dort gibt es den Tab nicht mehr, und der Weg führt
|
||||
/// über das Monogramm oben rechts.
|
||||
@State private var showsSettings = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch session.phase {
|
||||
case .launching:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.haloCanvas)
|
||||
|
||||
case .signedOut:
|
||||
SignInView()
|
||||
|
||||
case .connecting(let name):
|
||||
ConnectingView(serverName: name)
|
||||
|
||||
case .failed(let message):
|
||||
ConnectionFailureView(
|
||||
message: message,
|
||||
onRetry: { Task { await session.retry() } },
|
||||
onSignOut: { session.signOut() }
|
||||
)
|
||||
|
||||
case .ready:
|
||||
library
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.environment(topChrome)
|
||||
// **Aus dem Fenster, nicht aus dem Layout.**
|
||||
//
|
||||
// Hier stand `proxy.safeAreaInsets.top` — ein Wert, der beim Drehen
|
||||
// erst eine Runde später stimmt. Nachgemessen sah man das genau
|
||||
// einmal: quer war die Seitenleiste zuerst 620,67 Punkte hoch und
|
||||
// dann 558,67. Die Differenz sind zweiundsechzig, und die kamen von
|
||||
// `.padding(.top, statusBarHeight)` an der Leiste weiter unten, das
|
||||
// noch den Wert aus dem Hochformat trug.
|
||||
//
|
||||
// Das Fenster kennt seinen Rand ohne Umweg über das Layout, das er
|
||||
// selbst beeinflusst. Die Grösse bleibt der Anlass zum Nachfragen,
|
||||
// der **Wert** kommt von aussen — und stimmt damit im ersten
|
||||
// Durchgang.
|
||||
//
|
||||
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
||||
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile
|
||||
// spart eine Layoutrunde, mehr nicht.
|
||||
.onGeometryChange(for: CGSize.self) { proxy in
|
||||
proxy.size
|
||||
} action: { _ in statusBarHeight = ScreenSafeArea.current.top }
|
||||
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
||||
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||
.haloAccent(theme)
|
||||
.tint(theme.accentColor)
|
||||
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
||||
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
||||
.alert(
|
||||
Text("quickPlay.failed"),
|
||||
isPresented: Binding(
|
||||
get: { quickPlay.failure != nil },
|
||||
set: { if !$0 { quickPlay.failure = nil } }
|
||||
)
|
||||
) {
|
||||
Button("action.close", role: .cancel) { quickPlay.failure = nil }
|
||||
} message: {
|
||||
if let grund = quickPlay.failure { Text(grund) }
|
||||
}
|
||||
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
||||
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
||||
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
|
||||
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
|
||||
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
|
||||
// ist, und der Übergang ist ruhig.
|
||||
.overlay {
|
||||
if playback.presentation == .full, let player {
|
||||
// **Bildfüllend, und der Player passt drinnen selbst auf.**
|
||||
//
|
||||
// Ohne diese Zeile verliert die Bildfläche ihr
|
||||
// `ignoresSafeArea` mit — das Bild wäre im Querformat um die
|
||||
// Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der
|
||||
// Player deshalb aus dem Fenster; siehe `ScreenSafeArea`.
|
||||
PlayerView(model: player)
|
||||
.ignoresSafeArea()
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.animation(.snappy(duration: 0.3), value: playback.presentation)
|
||||
.onChange(of: playback.request) { _, request in
|
||||
Task { await handOver(to: request) }
|
||||
}
|
||||
.task { await session.restore() }
|
||||
// **Kommt das Netz zurück, geht es weiter.**
|
||||
//
|
||||
// Ein Download, den ein Funkloch unterbrochen hat, lag bisher da, bis
|
||||
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
||||
// Stunde wieder stand.
|
||||
//
|
||||
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht
|
||||
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
||||
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
||||
.onChange(of: network.isReachable) { _, erreichbar in
|
||||
guard erreichbar else { return }
|
||||
Task { await downloads.resumeInterrupted() }
|
||||
}
|
||||
.onChange(of: session.phase) { _, newPhase in
|
||||
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
||||
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
||||
if newPhase == .ready, case .library = selection { selection = .home }
|
||||
}
|
||||
.onChange(of: sizeClass) { _, _ in
|
||||
// Beim Drehen oder Teilen des Fensters wechselt die Form der
|
||||
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
|
||||
// Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst
|
||||
// einen leeren Bereich.
|
||||
switch selection {
|
||||
// `.settings` gehört dazu, seit es den Tab auf dem iPhone nicht
|
||||
// mehr gibt: wer am iPad in den Einstellungen steht und das
|
||||
// Fenster schmal zieht, stünde sonst vor einem Bereich, den die
|
||||
// Leiste gar nicht führt.
|
||||
case .library, .libraryHub, .liveTV, .settings: selection = .home
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wechselt den laufenden Titel.
|
||||
///
|
||||
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
|
||||
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
|
||||
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
|
||||
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
|
||||
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
|
||||
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
||||
/// abgestimmt, bevor es hier ankommt.
|
||||
private func handOver(to request: PlaybackRequest?) async {
|
||||
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
|
||||
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
|
||||
// weiter, während der neue startet, streiten sich zwei um dieselbe
|
||||
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
|
||||
// als neue Wiedergabe, die stumm blieb.
|
||||
let previous = player
|
||||
previous?.silence()
|
||||
player = nil
|
||||
if let previous { Task { await previous.releaseOnServer() } }
|
||||
|
||||
guard let request else { return }
|
||||
|
||||
let fresh = PlayerModel(
|
||||
request: request,
|
||||
backend: session.backend,
|
||||
liveTV: session.liveTV,
|
||||
downloads: downloads,
|
||||
diagnostics: diagnostics,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
languages: languages,
|
||||
subtitles: subtitleAppearance,
|
||||
// **Vom Server erfragt, nicht aus der Adresse geschlossen.** Wer
|
||||
// über einen Reverse Proxy geht, erreicht seinen Server auch von
|
||||
// der Couch aus unter einem öffentlichen Namen — ihn deshalb als
|
||||
// entfernt zu führen hiesse, „Auto" würde ein 67-Mbit-Remux
|
||||
// zuhause am Kabel ohne Not herunterrechnen.
|
||||
isLocalConnection: session.isLocalConnection,
|
||||
connectionDescription: session.connectionDescription,
|
||||
unavailableMessage: session.playbackUnavailableMessage
|
||||
)
|
||||
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
|
||||
// der Kachel — dort kam man vorher gar nicht mehr heraus.
|
||||
fresh.onPictureInPictureRestore { playback.expand() }
|
||||
|
||||
player = fresh
|
||||
await fresh.start()
|
||||
}
|
||||
|
||||
/// Die Navigation.
|
||||
///
|
||||
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
|
||||
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
|
||||
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
|
||||
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
|
||||
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
|
||||
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
|
||||
/// Jahr vorgibt.
|
||||
///
|
||||
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
|
||||
/// ausfahrbares Sheet, eine geteilte Ansicht und eine Reihe oben. Sie
|
||||
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
|
||||
/// gepflegt werden.
|
||||
/// Läuft diese App gerade auf einem Mac?
|
||||
///
|
||||
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
|
||||
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
|
||||
/// Unterschied sichtbar.
|
||||
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
|
||||
|
||||
private var tabs: some View {
|
||||
TabView(selection: $selection) {
|
||||
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
||||
// **Innerhalb des Stapels, nicht darum herum.** Als Überzug
|
||||
// über `mediaStack` lag er auch über jedem **geschobenen**
|
||||
// Bildschirm — auf einer Detailseite also über deren
|
||||
// Drei-Punkte-Menü, zwei Knöpfe übereinander. Hier gilt er
|
||||
// für die Startseite und endet mit ihr.
|
||||
//
|
||||
// Dort ist ein Überzug der einzige Platz: die Startseite
|
||||
// schaltet ihre Navigationsleiste ab, ein `ToolbarItem`
|
||||
// erschiene gar nicht.
|
||||
mediaStack {
|
||||
HomeView()
|
||||
.overlay(alignment: .topTrailing) { profileButton }
|
||||
}
|
||||
}
|
||||
.customizationID("halo.tab.home")
|
||||
|
||||
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
||||
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
||||
// die Leiste unten hat fünf Plätze, und alles darüber schiebt das
|
||||
// System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei
|
||||
// Navigationsleisten übereinander tragen, eine vom System und eine
|
||||
// von uns.
|
||||
if sizeClass == .regular {
|
||||
TabSection {
|
||||
ForEach(session.libraries) { library in
|
||||
Tab(
|
||||
value: SidebarDestination.library(key: library.id, title: library.title)
|
||||
) {
|
||||
mediaStack { LibraryView(library: library) }
|
||||
} label: {
|
||||
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
||||
}
|
||||
.customizationID("halo.tab.library.\(library.id)")
|
||||
}
|
||||
} header: {
|
||||
Text("sidebar.libraries")
|
||||
}
|
||||
.customizationID("halo.tab.libraries")
|
||||
|
||||
if session.supportsLiveTV {
|
||||
Tab(value: SidebarDestination.liveTV) {
|
||||
mediaStack { LiveTVView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.customizationID("halo.tab.liveTV")
|
||||
}
|
||||
} else {
|
||||
Tab(value: SidebarDestination.libraryHub) {
|
||||
mediaStack { LibraryBrowser() }
|
||||
} label: {
|
||||
tabLabel("sidebar.library", symbol: "books.vertical")
|
||||
}
|
||||
.customizationID("halo.tab.libraryHub")
|
||||
}
|
||||
|
||||
Tab(value: SidebarDestination.downloads) {
|
||||
mediaStack { DownloadsView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
||||
}
|
||||
.customizationID("halo.tab.downloads")
|
||||
|
||||
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
||||
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
||||
// sind nichts, was man im Wechsel mit der Mediathek benutzt; ein
|
||||
// Platz in der Leiste unten unterstellt genau das. In der
|
||||
// Seitenleiste bleibt der Eintrag, dort ist Platz.
|
||||
if sizeClass == .regular {
|
||||
Tab(value: SidebarDestination.settings) {
|
||||
mediaStack { SettingsView() }
|
||||
} label: {
|
||||
tabLabel("sidebar.settings", symbol: "gearshape")
|
||||
}
|
||||
.customizationID("halo.tab.settings")
|
||||
}
|
||||
|
||||
// Die Suche nur dort, wo Platz für sie ist.
|
||||
//
|
||||
// Die Leiste unten hat fünf Plätze. Mit der Suche wäre einer davon
|
||||
// für etwas belegt, das man in einer Mediathek sucht und nicht in
|
||||
// einer Leiste — deshalb sitzt sie auf dem iPhone über der
|
||||
// Mediathek, neben Filter und Ansicht.
|
||||
if sizeClass == .regular {
|
||||
Tab(value: SidebarDestination.search, role: .search) {
|
||||
mediaStack { SearchView() }
|
||||
}
|
||||
.customizationID("halo.tab.search")
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tabViewCustomization($customization)
|
||||
}
|
||||
|
||||
/// Die Navigation, wie **wir** sie zeichnen.
|
||||
///
|
||||
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
|
||||
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
|
||||
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
|
||||
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
|
||||
///
|
||||
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
|
||||
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
|
||||
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
|
||||
/// zeichnen wir es selbst.
|
||||
///
|
||||
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
|
||||
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
|
||||
private var library: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if sizeClass == .regular {
|
||||
HStack(spacing: 0) {
|
||||
if placement == .sidebar {
|
||||
HaloSidebar(
|
||||
groups: navigationGroups,
|
||||
selection: $selection,
|
||||
placement: $placement.animation(Self.placementAnimation),
|
||||
layout: navigationLayout
|
||||
)
|
||||
// Sie klappt nach **oben** weg und kommt von oben
|
||||
// zurück — und die Leiste oben schiebt sich seitwärts
|
||||
// herein beziehungsweise hinaus. Zusammen sieht es aus,
|
||||
// als zöge dieselbe Leiste um: erst zusammenklappen,
|
||||
// dann hinüberschieben.
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
// Die Statusleiste gehört ihr nicht. Seit der Baum den
|
||||
// Sicherheitsrand ignoriert, säße ihre Oberkante sonst
|
||||
// unter der Uhrzeit.
|
||||
.padding(.top, statusBarHeight)
|
||||
}
|
||||
|
||||
tabs
|
||||
// Der Platz oben als **Sicherheitsrand**, nicht als
|
||||
// Abstand.
|
||||
//
|
||||
// Der Unterschied ist der schwarze Balken, über den du
|
||||
// dich zu Recht gewundert hast: mit einem Abstand hört
|
||||
// der Inhalt dort auf und die Fläche darüber bleibt für
|
||||
// immer leer. Als Sicherheitsrand fängt er im
|
||||
// Ruhezustand ebenso darunter an — aber beim Scrollen
|
||||
// wandert er **hindurch**, bis an den Bildschirmrand.
|
||||
// Die Leiste ist eine Insel; hinter einer Insel läuft
|
||||
// das Wasser durch.
|
||||
//
|
||||
// Anders als `safeAreaPadding` erreicht das auch die
|
||||
// geschobenen Bildschirme: deren Navigationsleisten
|
||||
// richten sich danach.
|
||||
//
|
||||
// Die Statusleiste ist mit eingerechnet, weil der Baum
|
||||
// sie gleich unten ignoriert. Ohne sie begänne der
|
||||
// Inhalt hinter der Uhrzeit.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Color.clear.frame(height: topInset)
|
||||
}
|
||||
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
||||
.overlay(alignment: .top) {
|
||||
if placement == .top {
|
||||
HaloTopBar(
|
||||
groups: navigationGroups.map(visible),
|
||||
selection: $selection,
|
||||
placement: $placement.animation(Self.placementAnimation)
|
||||
)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
.padding(.top, statusBarHeight)
|
||||
// Beim Scrollen fährt sie nach oben weg.
|
||||
//
|
||||
// Nur sie: der Sicherheitsrand darunter bleibt,
|
||||
// wie er ist. Schrumpfte er mit, rückte der
|
||||
// Inhalt bei jedem Ein- und Ausblenden nach —
|
||||
// man scrollt, und die Seite ruckt einem unter
|
||||
// dem Finger weg.
|
||||
.offset(y: topChrome.isRevealed ? 0 : -topInset)
|
||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hier reißt der Baum bis an den Bildschirmrand auf.
|
||||
//
|
||||
// Das ist der eigentliche Griff. Vorher saß alles — Leiste,
|
||||
// Bildschirme, Bildlaufansichten — innerhalb des
|
||||
// Sicherheitsrands, und deshalb konnte kein noch so gut
|
||||
// gesetzter Rand weiter unten den schwarzen Streifen über der
|
||||
// Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er.
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
} else {
|
||||
tabs
|
||||
}
|
||||
#else
|
||||
tabs
|
||||
#endif
|
||||
}
|
||||
// **Als Blatt, nicht als Bereich.** `SettingsView` ist ein Formular
|
||||
// aus geschobenen Unterbildschirmen und braucht nur einen
|
||||
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
||||
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
||||
// statt in einem Bereich, den man erst wieder verlassen muss.
|
||||
.environment(\.haloOpenSettings, { showsSettings = true })
|
||||
.sheet(isPresented: $showsSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
// Ein Blatt lässt sich nach unten wegziehen — aber nicht
|
||||
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
||||
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button("action.close") { showsSettings = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.miniPlayer(player, isVisible: playback.presentation == .mini)
|
||||
.onAppear { placement = storedPlacement }
|
||||
.onChange(of: placement) { _, new in
|
||||
storedPlacement = new
|
||||
// Zieht die Navigation um, soll die neue Leiste nicht
|
||||
// ausgeblendet ankommen.
|
||||
topChrome.isRevealed = true
|
||||
}
|
||||
// Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss.
|
||||
.onChange(of: topInset, initial: true) { _, new in topChrome.inset = new }
|
||||
}
|
||||
|
||||
/// Der Weg in die Einstellungen auf dem iPhone.
|
||||
///
|
||||
/// **Ein Überzug, kein Werkzeugleisten-Knopf.** Die Startseite schaltet
|
||||
/// ihre Navigationsleiste ganz ab — sie war leer und belegte trotzdem
|
||||
/// Höhe —, ein `ToolbarItem` erschiene dort also gar nicht. Hier liegt er
|
||||
/// einmal über allen Bildschirmen statt dreimal in deren Leisten, und er
|
||||
/// sitzt überall an derselben Stelle.
|
||||
///
|
||||
/// Er blendet sich beim Scrollen aus wie die Insel auf dem iPad: über
|
||||
/// einem Film, den man gerade sucht, hat ein Kontoknopf nichts verloren.
|
||||
@ViewBuilder
|
||||
private var profileButton: some View {
|
||||
Button {
|
||||
showsSettings = true
|
||||
} label: {
|
||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
||||
.padding(HaloSpacing.snug)
|
||||
// Damit er auch über einem hellen Aufmacher steht.
|
||||
.background(.black.opacity(0.28), in: .circle)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, HaloSpacing.regular)
|
||||
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
||||
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
||||
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
||||
.padding(.top, HaloSpacing.snug)
|
||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||
.allowsHitTesting(topChrome.isRevealed)
|
||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||
.accessibilityLabel(Text("sidebar.settings"))
|
||||
}
|
||||
|
||||
/// Wie viel Platz die Leiste oben braucht.
|
||||
private static let topBarHeight: CGFloat = 52
|
||||
|
||||
/// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt.
|
||||
///
|
||||
/// Die Statusleiste plus das, was die Navigation dort belegt — bei der
|
||||
/// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe.
|
||||
///
|
||||
/// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt
|
||||
/// schwebt nichts, worunter man greifen müsste.
|
||||
private var topInset: CGFloat {
|
||||
guard sizeClass == .regular else { return 0 }
|
||||
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight)
|
||||
}
|
||||
|
||||
private var navigationGroups: [NavigationGroup] {
|
||||
var groups: [NavigationGroup] = [
|
||||
NavigationGroup(id: "main", title: nil, entries: [
|
||||
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house", plainTitle: String(localized: "sidebar.home")),
|
||||
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle", plainTitle: String(localized: "sidebar.downloads")),
|
||||
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape", plainTitle: String(localized: "sidebar.settings")),
|
||||
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass", plainTitle: String(localized: "sidebar.search")),
|
||||
])
|
||||
]
|
||||
|
||||
var media = session.libraries.map { library in
|
||||
NavigationEntry(
|
||||
destination: .library(key: library.id, title: library.title),
|
||||
title: Text(library.title),
|
||||
symbol: library.kind.symbol,
|
||||
plainTitle: library.title
|
||||
)
|
||||
}
|
||||
if session.supportsLiveTV {
|
||||
media.append(NavigationEntry(
|
||||
destination: .liveTV,
|
||||
title: Text("sidebar.liveTV"),
|
||||
symbol: "dot.radiowaves.left.and.right",
|
||||
plainTitle: String(localized: "sidebar.liveTV")
|
||||
))
|
||||
}
|
||||
if !media.isEmpty {
|
||||
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/// Eine Gruppe so, wie sie dastehen soll — ausgeblendete Einträge raus,
|
||||
/// Reihenfolge angewandt. Die Seitenleiste macht das selbst, weil sie zum
|
||||
/// Bearbeiten auch die ausgeblendeten braucht.
|
||||
private func visible(_ group: NavigationGroup) -> NavigationGroup {
|
||||
NavigationGroup(
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
entries: navigationLayout.apply(to: group.entries)
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
|
||||
///
|
||||
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
|
||||
/// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon
|
||||
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
|
||||
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
|
||||
/// nicht; es passiert einfach nichts.
|
||||
private func mediaStack<Content: View>(@ViewBuilder content: @escaping () -> Content) -> some View {
|
||||
MediaStack(content: content)
|
||||
}
|
||||
|
||||
/// Die Breite der Seitenleiste auf dem Mac.
|
||||
///
|
||||
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
|
||||
/// kurzen Wörtern wie „Start" so weit zusammen, dass der Kontoname darunter
|
||||
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
|
||||
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
|
||||
/// was eine Seitenleiste auf dem iPad breit ist.
|
||||
#if os(macOS) || targetEnvironment(macCatalyst)
|
||||
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
|
||||
(min: 190, ideal: 230, max: 320)
|
||||
#endif
|
||||
|
||||
/// Die Beschriftung eines Eintrags.
|
||||
///
|
||||
/// **Ein** Aufruf, nicht zwei. Es gab hier eine zweite Fassung für Titel,
|
||||
/// die vom Server kommen — mit `String` statt `LocalizedStringKey`. Swift
|
||||
/// darf sich bei einem Literal für beide entscheiden, und es entschied sich
|
||||
/// nicht überall gleich: in der Seitenleiste stand plötzlich
|
||||
/// „sidebar.downloads" statt „Downloads". Wer einen fertigen Text hat,
|
||||
/// baut sein `Text` jetzt selbst.
|
||||
private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View {
|
||||
sidebarLabel(Text(title), symbol: symbol)
|
||||
}
|
||||
|
||||
private func sidebarLabel(_ title: Text, symbol: String) -> some View {
|
||||
let label = Label { title } icon: { Image(systemName: symbol) }
|
||||
#if os(macOS)
|
||||
return label.frame(
|
||||
minWidth: Self.sidebarWidth.min - 40,
|
||||
maxWidth: Self.sidebarWidth.max - 40,
|
||||
alignment: .leading
|
||||
)
|
||||
#else
|
||||
return label
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Was in der Seitenleiste steht.
|
||||
///
|
||||
/// Die Mediatheken kommen erst zur Laufzeit vom Server, deshalb trägt
|
||||
/// `library` den Schlüssel des Abschnitts statt eines festen Falls.
|
||||
enum SidebarDestination: Hashable {
|
||||
case home
|
||||
case library(key: String, title: String)
|
||||
/// Alle Mediatheken zusammen — auf schmalen Bildschirmen an ihrer Stelle.
|
||||
case libraryHub
|
||||
case search
|
||||
case liveTV
|
||||
case downloads
|
||||
case settings
|
||||
|
||||
/// Eine feste Kennung zum Speichern.
|
||||
///
|
||||
/// **Ohne** den Titel: benennt jemand eine Mediathek auf dem Server um,
|
||||
/// soll sie ihren Platz in der Seitenleiste behalten und nicht als neuer
|
||||
/// Eintrag ganz unten auftauchen.
|
||||
var storageKey: String {
|
||||
switch self {
|
||||
case .home: "home"
|
||||
case .library(let key, _): "library:\(key)"
|
||||
case .libraryHub: "libraryHub"
|
||||
case .search: "search"
|
||||
case .liveTV: "liveTV"
|
||||
case .downloads: "downloads"
|
||||
case .settings: "settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Titel in der Navigationsleiste.
|
||||
///
|
||||
/// Auf der Startseite steht dort der Name der App — in der Seitenleiste
|
||||
/// wäre das nur eine Dopplung der Überschrift, deshalb hat sie mit
|
||||
/// `sidebarLabel` einen eigenen Text.
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: Branding.appName
|
||||
case .library(_, let title): title
|
||||
case .libraryHub: String(localized: "sidebar.library")
|
||||
case .search: String(localized: "search.title")
|
||||
case .liveTV: String(localized: "sidebar.liveTV")
|
||||
case .downloads: String(localized: "sidebar.downloads")
|
||||
case .settings: String(localized: "sidebar.settings")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Beschriftung in der Seitenleiste.
|
||||
var sidebarLabel: String {
|
||||
switch self {
|
||||
case .home: String(localized: "sidebar.home")
|
||||
default: title
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .home: "house"
|
||||
case .library: "film"
|
||||
case .libraryHub: "books.vertical"
|
||||
case .search: "magnifyingglass"
|
||||
case .liveTV: "dot.radiowaves.left.and.right"
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .settings: "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Weg zur Suche, wenn sie kein eigener Eintrag ist.
|
||||
///
|
||||
/// Auf dem iPhone hat die Leiste unten fünf Plätze, und einer davon für die
|
||||
/// Suche ist einer zu viel — sie sitzt dort, wo man sie sucht: über der
|
||||
/// Mediathek, neben Filter und Ansicht.
|
||||
struct SearchRoute: Hashable {}
|
||||
@@ -0,0 +1,81 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Blendet die vom System gezeichnete Seitenleiste und Tableiste aus.
|
||||
///
|
||||
/// Der Grund ist das Aussehen, nicht das Verhalten. `UITabBarController`
|
||||
/// zeichnet seine Seitenleiste auf dem iPad als schwebende Fläche und auf dem
|
||||
/// Mac angeklebt am Fensterrand — und das lässt sich nicht umstellen.
|
||||
/// `preferredLayout = .overlap` ist die dafür vorgesehene Bitte; macOS verwirft
|
||||
/// sie stillschweigend. Gemessen, nicht vermutet.
|
||||
///
|
||||
/// Statt deshalb die ganze Navigation selbst zu bauen, bleibt der
|
||||
/// `TabView` **stehen** und verliert nur sein Kleid: Auswahl, getrennte
|
||||
/// Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln,
|
||||
/// Zustandspflege — all das macht weiter das System. Gezeichnet wird darüber.
|
||||
///
|
||||
/// Dass das geht, war kein Ratschluss: derselbe Griff hat die Leiste
|
||||
/// versuchsweise schon einmal verschwinden lassen.
|
||||
struct TabChromeSuppressor: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController { Probe() }
|
||||
func updateUIViewController(_ controller: UIViewController, context: Context) {}
|
||||
|
||||
/// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen.
|
||||
private final class Probe: UIViewController {
|
||||
override func didMove(toParent parent: UIViewController?) {
|
||||
super.didMove(toParent: parent)
|
||||
suppress()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// Ein zweites Mal: beim ersten Anhängen steht der Tab-Controller
|
||||
// noch nicht zwingend im Baum.
|
||||
suppress()
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
// Und bei jedem Umbau: das System stellt die Leisten wieder her,
|
||||
// wenn sich die Größenklasse ändert — beim Drehen etwa.
|
||||
suppress()
|
||||
}
|
||||
|
||||
private func suppress() {
|
||||
guard let controller = Self.tabBarController(from: self) else { return }
|
||||
|
||||
// Erst die Form festlegen, dann ausblenden — und zwar auf die
|
||||
// Tableiste, nicht auf die Seitenleiste.
|
||||
//
|
||||
// Nur `sidebar.isHidden = true` reichte nicht: auf dem Mac stand
|
||||
// die System-Seitenleiste danach wieder da, mit eigenem Pfeil und
|
||||
// eigenem Knopf, hinter unserer. Solange `mode` auf `tabSidebar`
|
||||
// steht, gehört die Seitenleiste zur Darstellung und das System
|
||||
// stellt sie wieder her. Steht `mode` auf `tabBar`, gibt es keine
|
||||
// Seitenleiste mehr, die zurückkommen könnte — und die Tableiste
|
||||
// ist ausgeblendet.
|
||||
if controller.mode != .tabBar { controller.mode = .tabBar }
|
||||
if !controller.isTabBarHidden { controller.isTabBarHidden = true }
|
||||
if !controller.sidebar.isHidden { controller.sidebar.isHidden = true }
|
||||
}
|
||||
|
||||
/// Sucht vom eigenen Platz aus nach oben bis zur Wurzel und von dort
|
||||
/// nach unten: die Leiste liegt in der SwiftUI-Sicht *über* uns, die
|
||||
/// reine Elternkette führt an ihr vorbei.
|
||||
private static func tabBarController(from start: UIViewController) -> UITabBarController? {
|
||||
var root = start
|
||||
while let parent = root.parent { root = parent }
|
||||
return search(root)
|
||||
}
|
||||
|
||||
private static func search(_ controller: UIViewController) -> UITabBarController? {
|
||||
if let found = controller as? UITabBarController { return found }
|
||||
for child in controller.children {
|
||||
if let found = search(child) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// Was oben über dem Inhalt schwebt — und wie viel Platz es einnimmt.
|
||||
///
|
||||
/// Zwei Dinge, die zusammengehören und deshalb an einer Stelle liegen: die
|
||||
/// Höhe, die Statusleiste und Insel zusammen belegen, und ob die Insel gerade
|
||||
/// zu sehen ist.
|
||||
///
|
||||
/// Der Umweg über ein geteiltes Objekt ist nötig, weil beide Enden weit
|
||||
/// auseinanderliegen. Die Höhe kennt nur `RootView` — sie hängt am Gerät und
|
||||
/// daran, ob die Navigation an der Seite oder oben sitzt. Brauchen tut sie der
|
||||
/// Aufmacher, der unter ihr durchgreifen soll, und der sitzt drei Ebenen
|
||||
/// tiefer in einem eigenen Navigationsstapel. Umgekehrt weiß nur die
|
||||
/// Bildlaufansicht des gerade sichtbaren Bildschirms, ob man oben steht —
|
||||
/// entscheiden muss es `RootView`.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TopChrome {
|
||||
/// Wie hoch Statusleiste und Insel zusammen bauen.
|
||||
///
|
||||
/// Der Wert, um den der Inhalt nach unten beginnt. Wer ihn überspringen
|
||||
/// und bis an den Bildschirmrand greifen will, zieht sich um genau diese
|
||||
/// Strecke nach oben.
|
||||
var inset: CGFloat = 0
|
||||
|
||||
/// Ob die Insel gerade zu sehen ist.
|
||||
///
|
||||
/// Steht man ganz oben, ist sie da. Beim Scrollen fährt sie weg — der
|
||||
/// Inhalt ist dann das Wichtigere, und die Navigation kommt zurück, sobald
|
||||
/// man wieder oben ankommt.
|
||||
var isRevealed = true
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Meldet dieser Bildlaufansicht an, ob sie ganz oben steht.
|
||||
///
|
||||
/// Gehört an die **äußere, senkrechte** Bildlaufansicht eines Bildschirms.
|
||||
/// Nicht an die waagerechten Reihen darin: `onScrollGeometryChange` meldet
|
||||
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
|
||||
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
|
||||
/// weit unten steht.
|
||||
func haloRevealsTopChrome() -> some View {
|
||||
modifier(RevealsTopChrome())
|
||||
}
|
||||
}
|
||||
|
||||
private struct RevealsTopChrome: ViewModifier {
|
||||
@Environment(TopChrome.self) private var chrome
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
// Der Sicherheitsrand gehört dazugerechnet: `contentOffset` allein
|
||||
// beginnt bei einer Ansicht mit Rand oben im Negativen, und „ganz
|
||||
// oben" wäre dann nie null.
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.y + geometry.contentInsets.top
|
||||
} action: { _, offset in
|
||||
// Eine Schwelle statt genau null: das Gummiband am oberen Ende
|
||||
// liefert auch im Ruhezustand Werte, die um Bruchteile eines
|
||||
// Punktes schwanken, und die Insel flackerte sonst.
|
||||
chrome.isRevealed = offset <= 1
|
||||
}
|
||||
// Wer einen Bildschirm verlässt, während er weit unten steht, käme
|
||||
// sonst auf dem nächsten ohne Navigation an.
|
||||
.onDisappear { chrome.isRevealed = true }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user