Mediatheken nach oben — und das Regal sagt endlich, was es sieht

**Die Mediatheken sind Reiter geworden.** Sie standen unten auf der
Startseite, und dorthin musste man erst scrollen — an allen Vorschlägen
vorbei, obwohl „ich will in die Filme" genau der Fall ist, in dem einem
die Vorschläge egal sind. Damit fällt die Reihe unten weg: zwei Wege zum
selben Raster sind auf einem Fernseher kein Komfort, sondern eine Frage,
welcher der richtige ist.

Die Leiste liest sich jetzt „Start · Filme · Serien · Gemischtes ·
Suchen" und wird von tvOS mittig gehalten, egal wie viele Mediatheken
dazukommen. Die Stelle der Suche bestimmt Apple, nicht diese Datei:
`role: .search` setzt sie dorthin, wo sie in jeder anderen App auch
steht, und gibt ihr die Lupe.

**Und eine Statuszeile fürs Regal.** Bleibt es leer, gibt es genau drei
Möglichkeiten: die App-Gruppe fehlt, die Datei ist nicht da, oder sie ist
da und das System fragt trotzdem nicht. Von aussen sehen alle drei gleich
aus — nämlich nach dem grossen Plakat aus dem Asset-Katalog. Nach zwei
Runden Vermuten steht unten links jetzt, welcher Fall es ist: „3 Reihen,
9 Einträge, zuletzt 21:55" oder eben „Keine App-Gruppe".

Nur im Entwicklerbau (`#if DEBUG`) und als Überlagerung, nicht als letzte
Zeile der Liste: dorthin müsste man erst an allen Reihen vorbeiscrollen,
und wer nachsieht, warum das Regal leer ist, soll es sofort sehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 21:56:31 +02:00
co-authored by Claude Opus 5
parent 08514eb564
commit 41719595b9
4 changed files with 162 additions and 51 deletions
+85
View File
@@ -7398,6 +7398,91 @@
} }
} }
} }
},
"topShelf.noFile": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch nichts abgelegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing stored yet."
}
}
}
},
"topShelf.noGroup": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine App-Gruppe — das Regal kann nichts lesen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No app group — the shelf cannot read anything."
}
}
}
},
"topShelf.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Regal über der App"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Top Shelf"
}
}
}
},
"topShelf.unreadable": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Datei liegt da, lässt sich aber nicht lesen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "File is there but cannot be read."
}
}
}
},
"topShelf.written %lld %lld %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Reihen, %lld Einträge, zuletzt %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld rows, %lld entries, last %@"
}
}
}
} }
}, },
"version": "1.1" "version": "1.1"
+22
View File
@@ -86,5 +86,27 @@ enum TopShelfWriter {
// wenn sie gefragt wird. // wenn sie gefragt wird.
TVTopShelfContentProvider.topShelfContentDidChange() TVTopShelfContentProvider.topShelfContentDidChange()
} }
/// Was zuletzt abgelegt wurde — in einem Satz, zum Hinsehen.
///
/// **Weil man dem Regal sonst nichts ansieht.** Bleibt es leer, gibt es
/// genau drei Möglichkeiten: die App-Gruppe fehlt, die Datei ist nicht da,
/// oder sie ist da und das System fragt trotzdem nicht. Von aussen sehen
/// alle drei gleich aus — nämlich nach dem grossen Plakat aus dem
/// Asset-Katalog. Diese Zeile unterscheidet sie.
static var status: String {
guard let ort = TopShelfStore.location(group: group) else {
return String(localized: "topShelf.noGroup")
}
guard let abzug = TopShelfStore.read(group: group) else {
let daIst = FileManager.default.fileExists(atPath: ort.path)
return daIst
? String(localized: "topShelf.unreadable")
: String(localized: "topShelf.noFile")
}
let einträge = abzug.sections.reduce(0) { $0 + $1.entries.count }
let zeit = abzug.savedAt.formatted(date: .omitted, time: .shortened)
return String(localized: "topShelf.written \(abzug.sections.count) \(einträge) \(zeit)")
}
} }
#endif #endif
+24 -37
View File
@@ -38,9 +38,6 @@ struct TVHomeView: View {
.navigationDestination(for: MediaRoute.self) { route in .navigationDestination(for: MediaRoute.self) { route in
TVDetailView(route: route) TVDetailView(route: route)
} }
.navigationDestination(for: LibraryRoute.self) { route in
TVLibraryView(libraryID: route.id)
}
} }
.task(id: session.serverName) { await feed.load(from: session.backend) } .task(id: session.serverName) { await feed.load(from: session.backend) }
.onChange(of: deepLinkTarget) { _, neu in .onChange(of: deepLinkTarget) { _, neu in
@@ -96,10 +93,6 @@ struct TVHomeView: View {
onFocus: { aufmacher = $0 } onFocus: { aufmacher = $0 }
) )
} }
if !session.libraries.isEmpty {
mediatheken
}
} }
.padding(.bottom, FlixySpacing.screenMargin) .padding(.bottom, FlixySpacing.screenMargin)
} }
@@ -108,40 +101,34 @@ struct TVHomeView: View {
// Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das // Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das
// achtzig Punkte davor aufhört, sieht nach Fehler aus. // achtzig Punkte davor aufhört, sieht nach Fehler aus.
.ignoresSafeArea() .ignoresSafeArea()
// Als Überlagerung und nicht als letzte Zeile der Liste: dorthin
// müsste man erst an allen Reihen vorbeiscrollen, und wer nachsieht,
// warum das Regal leer ist, soll es sofort sehen.
.overlay(alignment: .bottom) { regalStatus }
} }
/// Die Mediatheken als eigene Reihe. /// Was das Regal über der App zuletzt bekommen hat.
/// ///
/// Unten und nicht oben: wer den Fernseher einschaltet, will meistens /// **Nur im Entwicklerbau.** Es gehört nicht in die fertige App — aber
/// weiterschauen, nicht stöbern. Und als Reihe statt als Registerkarten, /// solange das Regal leer bleibt, sieht man von aussen nicht, woran es
/// weil eine Reihe am Fokus hängt und Registerkarten eine zweite Bedienart /// liegt: fehlende App-Gruppe, fehlende Datei und ein System, das nicht
/// wären. /// fragt, sehen alle gleich aus. Diese Zeile unterscheidet sie.
private var mediatheken: some View { @ViewBuilder
VStack(alignment: .leading, spacing: FlixySpacing.regular) { private var regalStatus: some View {
Text("library.title") #if DEBUG
.font(.flixyRowHeader) VStack(alignment: .leading, spacing: FlixySpacing.tight) {
.foregroundStyle(Color.flixyTextPrimary) Text("topShelf.title")
.padding(.horizontal, FlixySpacing.screenMargin) .font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
ScrollView(.horizontal) { Text(TopShelfWriter.status)
LazyHStack(spacing: FlixySpacing.regular) { .font(.flixyCaption)
ForEach(session.libraries, id: \.id) { mediathek in .foregroundStyle(Color.flixyTextSecondary)
Button {
pfad.append(LibraryRoute(id: mediathek.id))
} label: {
Text(mediathek.title)
.font(.flixyRowHeader)
.frame(width: 360, height: 180)
}
.buttonStyle(.card)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.regular)
}
.scrollClipDisabled()
.scrollIndicators(.never)
} }
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.bottom, FlixySpacing.regular)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black.opacity(0.6))
#endif
} }
// MARK: - Was ein Druck auslöst // MARK: - Was ein Druck auslöst
+31 -14
View File
@@ -1,22 +1,21 @@
import SwiftUI import SwiftUI
import FlixyDesign import FlixyDesign
import MediaKit
/// Start und Suche nebeneinander. /// Start, die Mediatheken und die Suche nebeneinander.
/// ///
/// **Ein `TabView`, und erst jetzt.** Bis hierher gab es genau einen /// **Die Mediatheken als Reiter, nicht als Reihe.** Sie standen unten auf der
/// Bildschirm, und eine Leiste mit einem einzigen Eintrag wäre Zierrat /// Startseite, und dorthin musste man erst scrollen — an allen Vorschlägen
/// gewesen. Mit der Suche gibt es zwei Orte, zwischen denen man wechselt — /// vorbei, obwohl „ich will in die Filme" genau der Fall ist, in dem einem
/// und dafür ist die Leiste oben die Bedienung, die jeder vom Fernseher /// die Vorschläge egal sind. Oben sind sie einen Wisch entfernt.
/// kennt: sie erscheint, wenn man vom obersten Element aus nach oben wischt,
/// und verschwindet wieder.
/// ///
/// **Kein Reiter für die Mediatheken.** Die stehen als Reihe auf der /// Damit fällt die Reihe unten weg: zwei Wege zum selben Raster sind auf
/// Startseite. Ein dritter Reiter dafür verspräche einen eigenen Bereich, und /// einem Fernseher kein Komfort, sondern eine Frage, welcher der richtige ist.
/// dahinter läge dasselbe Raster, das eine Reihe tiefer ohnehin erreichbar
/// ist.
struct TVShell: View { struct TVShell: View {
@Binding var deepLinkTarget: MediaRoute? @Binding var deepLinkTarget: MediaRoute?
@Environment(FlixySession.self) private var session
/// Welcher Reiter offen ist. /// Welcher Reiter offen ist.
/// ///
/// Ausdrücklich gehalten, nicht dem System überlassen: ein Link aus dem /// Ausdrücklich gehalten, nicht dem System überlassen: ein Link aus dem
@@ -24,7 +23,11 @@ struct TVShell: View {
/// Suche offen, passierte sichtbar nichts. /// Suche offen, passierte sichtbar nichts.
@State private var reiter = Reiter.start @State private var reiter = Reiter.start
private enum Reiter: Hashable { case start, suche } private enum Reiter: Hashable {
case start
case mediathek(String)
case suche
}
var body: some View { var body: some View {
TabView(selection: $reiter) { TabView(selection: $reiter) {
@@ -34,9 +37,23 @@ struct TVShell: View {
Text("sidebar.home") Text("sidebar.home")
} }
ForEach(session.libraries, id: \.id) { mediathek in
Tab(value: Reiter.mediathek(mediathek.id)) {
// Jeder Reiter bekommt seinen eigenen Stapel. Ein
// gemeinsamer hiesse, dass ein Wechsel des Reiters die
// Detailseite des vorigen mitnimmt.
NavigationStack {
TVLibraryView(libraryID: mediathek.id)
}
} label: {
Text(mediathek.title)
}
}
// **`role: .search` und nicht ein gewöhnlicher Reiter.** tvOS // **`role: .search` und nicht ein gewöhnlicher Reiter.** tvOS
// stellt ihn dadurch ans Ende der Leiste und gibt ihm das // setzt ihn dadurch dorthin, wo er in jeder anderen App auch
// Lupensymbol — dieselbe Stelle wie in Apples eigenen Apps. // steht, und gibt ihm die Lupe. Die Stelle bestimmt also Apple,
// nicht diese Datei.
Tab(value: Reiter.suche, role: .search) { Tab(value: Reiter.suche, role: .search) {
TVSearchView() TVSearchView()
} }