Files
Flaro/Halo/Session/TopShelfWriter.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

113 lines
4.9 KiB
Swift

// **Die ganze Datei nur für den Fernseher.**
//
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
// TVServices" ab, ohne den Fernseher zu erwähnen. Dieselbe Falle wie bei
// siehe `PlaybackSettings`.
#if os(tvOS)
import Foundation
import MediaKit
import TVServices
/// Legt für das Regal über der App hin, was es zeigen soll.
///
/// **Beim Laden der Startseite, nicht auf einem Zeitgeber.** Das Regal wird
/// gezeichnet, wenn jemand im Menü über der App steht — und dann ist die App
/// nicht gestartet. Was dort steht, ist also immer das, was beim letzten
/// Öffnen zu sehen war. Ein Zeitgeber änderte daran nichts und hielte nur die
/// App wach.
@MainActor
enum TopShelfWriter {
/// Die App-Gruppe, die App und Erweiterung teilen.
static let group = "group.com.scarriffleservices.halo"
/// Die Kante, in der das Regal Plakate zeigt.
///
/// **Apples Mass für die doppelte Auflösung**, und für beide Stufen
/// dasselbe Bild. Ein Apple TV zeichnet das Regal ohnehin doppelt
/// aufgelöst; zwei Adressen abzulegen hiesse, dem Server zwei Grössen
/// desselben Plakats abzuverlangen, damit eine davon nie gebraucht wird.
private static let posterWidth = 666
private static let posterHeight = 1000
static func update(
rows: [MediaRowGroup],
backend: (any MediaBackend)?
) {
guard let backend else { return }
let abschnitte = rows
.prefix(TopShelfSnapshot.sectionLimit)
.map { reihe in
TopShelfSnapshot.Section(
id: reihe.id,
title: reihe.title,
entries: reihe.items
.prefix(TopShelfSnapshot.entryLimit)
.map { item in
TopShelfSnapshot.Entry(
id: item.id,
title: item.rowTitle,
subtitle: item.cardSubtitle,
// **Das Hochkant-Plakat, nicht `thumb`.** Bei
// einer Folge ist `thumb` das Szenenbild; im
// Regal stünde davon ein zufälliger
// Ausschnitt. Dieselbe Rückfallkette wie auf
// den Kacheln.
imageURL: backend.artworkURL(
path: item.posterArtworkPath,
width: posterWidth,
height: posterHeight
),
progress: item.watchProgress
)
}
)
}
.filter { !$0.entries.isEmpty }
TopShelfStore.write(
TopShelfSnapshot(sections: Array(abschnitte)),
group: group
)
// **Und dem System sagen, dass es neu fragen soll.**
//
// Ohne diese Zeile bleibt die Erweiterung ungefragt, und im Header
// steht, was dann geschieht: „If the completion handler is not called
// in a timely manner or is called with empty content the system will
// display the static image instead." Genau das war zu sehen — das
// Plakat aus dem Asset-Katalog, gross und unverändert, während die
// Datei daneben längst drei Reihen enthielt.
//
// Die Erweiterung selbst kann das nicht auslösen: sie läuft nur,
// wenn sie gefragt wird.
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