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>
131 lines
5.4 KiB
Swift
131 lines
5.4 KiB
Swift
import SwiftUI
|
|
import HaloDesign
|
|
import MediaKit
|
|
|
|
/// Eine Mediathek als Raster auf dem Fernseher.
|
|
///
|
|
/// **Nachgeladen wird am Fokus, nicht am Erscheinen.** Am Telefon hängt es
|
|
/// daran, dass eine Kachel sichtbar wird — bei sechzig Kacheln auf einem
|
|
/// breiten Raster erscheinen die aber alle beim ersten Zeichnen, und das
|
|
/// Nachladen liefe sofort los statt beim Blättern. Der Fokus steht dagegen
|
|
/// immer nur an einer Stelle, und genau die ist die Frage.
|
|
struct TVLibraryView: View {
|
|
let libraryID: String
|
|
|
|
@Environment(HaloSession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
|
|
@State private var feed: LibraryFeed?
|
|
@FocusState private var fokus: String?
|
|
|
|
/// Sechs Spalten auf 1760 Punkten Nutzbreite: rund 250 Punkte je Plakat,
|
|
/// aus drei Metern noch erkennbar.
|
|
private static let spalten = 6
|
|
|
|
private var raster: [GridItem] {
|
|
Array(repeating: GridItem(.flexible(), spacing: HaloSpacing.section), count: Self.spalten)
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let feed {
|
|
inhalt(feed: feed)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.task {
|
|
guard feed == nil,
|
|
let mediathek = session.libraries.first(where: { $0.id == libraryID })
|
|
else { return }
|
|
let frisch = LibraryFeed(library: mediathek)
|
|
feed = frisch
|
|
await frisch.reload(from: session.backend)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func inhalt(feed: LibraryFeed) -> some View {
|
|
switch feed.state {
|
|
case .loading where feed.items.isEmpty:
|
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
case .failed(let meldung) where feed.items.isEmpty:
|
|
VStack(spacing: HaloSpacing.regular) {
|
|
Text(meldung).font(.haloBody).foregroundStyle(.white)
|
|
Button("action.retry") { Task { await feed.reload(from: session.backend) } }
|
|
}
|
|
|
|
default:
|
|
raster(feed: feed)
|
|
}
|
|
}
|
|
|
|
private func raster(feed: LibraryFeed) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
|
Text(feed.library.title)
|
|
.font(.haloHero)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
|
|
|
LazyVGrid(columns: raster, spacing: HaloSpacing.section) {
|
|
ForEach(Array(feed.items.enumerated()), id: \.element.id) { stelle, item in
|
|
TVPosterCard(
|
|
title: item.rowTitle,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.backend?.artworkURL(
|
|
path: item.posterArtworkPath, width: 560, height: 840
|
|
),
|
|
progress: item.watchProgress
|
|
) {
|
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
|
// Raster eine Serie, hat die keine Fassungen; der
|
|
// direkte Weg schickte ihre eigene Kennung an den
|
|
// Player und scheiterte mit „keine abspielbare
|
|
// Datei". QuickPlay löst stattdessen die nächste
|
|
// ungesehene Folge auf.
|
|
//
|
|
// Ein Kontextmenü wie am Telefon gibt es hier
|
|
// nicht: langes Drücken ist auf der Fernbedienung
|
|
// keine verlässliche Geste.
|
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
|
}
|
|
.focused($fokus, equals: item.id)
|
|
.onChange(of: fokus) { _, neu in
|
|
guard neu == item.id,
|
|
feed.shouldLoadMore(at: stelle, columns: Self.spalten)
|
|
else { return }
|
|
Task { await feed.loadMore(from: session.backend) }
|
|
}
|
|
}
|
|
|
|
// **Die Platzhalter bleiben.** Am Fernseher sind sie
|
|
// wichtiger als am Telefon: hört das Raster einfach auf,
|
|
// läuft der Fokus gegen eine unsichtbare Wand. Sie dürfen
|
|
// ihn aber nicht annehmen, sonst landet er auf einem
|
|
// grauen Kasten.
|
|
ForEach(0..<min(feed.pendingCount, Self.spalten * 2), id: \.self) { _ in
|
|
PlaceholderCard()
|
|
.focusable(false)
|
|
}
|
|
}
|
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
|
|
|
if feed.loadMoreFailed {
|
|
Button("library.loadMore") {
|
|
Task { await feed.loadMore(from: session.backend) }
|
|
}
|
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
|
}
|
|
}
|
|
.padding(.vertical, HaloSpacing.screenMargin)
|
|
}
|
|
.scrollClipDisabled()
|
|
.scrollIndicators(.never)
|
|
// Siehe `HaloSpacing.screenMargin`: bildfüllend, Rand im Wert.
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|