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,120 @@
|
||||
import Foundation
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Woher der Aufmacher seine Bilder nimmt.
|
||||
///
|
||||
/// **Liegt bei den Modellen, obwohl der Name nach Ansicht klingt.** Hier steht
|
||||
/// keine Zeile SwiftUI, nur die Frage, welche Adresse in welcher Grösse
|
||||
/// angefordert wird — und die beantwortet der Fernseher genauso wie das
|
||||
/// Telefon.
|
||||
///
|
||||
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
|
||||
/// haben:
|
||||
///
|
||||
/// Erstens wurde das Hintergrundbild in 900×1200 angefordert — hochkant — und
|
||||
/// dann in einem 16:9-Rahmen gezeigt. Zu sehen war davon ein schmaler Streifen
|
||||
/// aus der Mitte; vom Bild fehlte oben und unten je ein Drittel. Die Maße
|
||||
/// richten sich jetzt nach dem Rahmen, in dem das Bild landet.
|
||||
///
|
||||
/// Zweitens holt das Karussell das nächste Bild vor, solange das aktuelle noch
|
||||
/// steht. Weicht dessen Adresse auch nur in einer Zahl ab, ist es eine andere
|
||||
/// Adresse, der Zwischenspeicher trifft daneben und der Aufmacher blendet auf
|
||||
/// eine graue Fläche über.
|
||||
enum HeroArtwork {
|
||||
/// Wofür das Bild gebraucht wird.
|
||||
///
|
||||
/// **Auf einem breiten Bildschirm macht das keinen Unterschied** — dort
|
||||
/// ist beides ein 16:9-Hintergrundbild über die ganze Breite. Schmal
|
||||
/// gehen die beiden auseinander, und zwar aus einem inhaltlichen Grund:
|
||||
///
|
||||
/// Im **Karussell** sucht man noch. Dort soll eine Szene stehen, kein
|
||||
/// Plakat: das Plakat trägt den Titel selbst, und darüber liegt noch der
|
||||
/// freigestellte Schriftzug — derselbe Name zweimal.
|
||||
///
|
||||
/// Auf einer **Detailseite** hat man sich entschieden. Dort ist das
|
||||
/// Plakat das Bild dieses Films, und der Kopf der Seite darf die Höhe
|
||||
/// nehmen, die ein Plakat braucht.
|
||||
enum Placement {
|
||||
/// Der Aufmacher auf der Startseite.
|
||||
case carousel
|
||||
/// Der Kopf einer Detailseite.
|
||||
case detailHeader
|
||||
}
|
||||
|
||||
/// Ob der Rahmen hochkant ist.
|
||||
///
|
||||
/// Die eine Stelle, an der die Entscheidung fällt — Maße, Verhältnis und
|
||||
/// Reihenfolge der Bildpfade hängen alle daran, und sie müssen
|
||||
/// zusammenpassen.
|
||||
private static func isPortrait(_ placement: Placement, compact: Bool) -> Bool {
|
||||
compact && placement == .detailHeader
|
||||
}
|
||||
|
||||
/// Die Maße, in denen das Bild angefordert wird.
|
||||
///
|
||||
/// Im Seitenverhältnis des Rahmens, in dem es landet — sonst schneidet der
|
||||
/// Server zu, und man sieht einen Streifen aus der Mitte.
|
||||
static func size(_ placement: Placement = .carousel, compact: Bool) -> (width: Int, height: Int) {
|
||||
if isPortrait(placement, compact: compact) { return (600, 900) }
|
||||
// Schmal landet das Querbild in einem 4:3-Rahmen von höchstens 440
|
||||
// Punkten Breite — bei dreifacher Auflösung rund 1320 Bildpunkte.
|
||||
// Zwölfhundert ist genug und spart gegenüber 1920 ein Drittel.
|
||||
return compact ? (1200, 900) : (1920, 1080)
|
||||
}
|
||||
|
||||
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
||||
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
|
||||
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
|
||||
}
|
||||
|
||||
static func backdropURL(
|
||||
for item: MediaItem,
|
||||
backend: any MediaBackend,
|
||||
placement: Placement = .carousel,
|
||||
compact: Bool
|
||||
) -> URL? {
|
||||
backdropURLs(for: item, backend: backend, placement: placement, compact: compact).first
|
||||
}
|
||||
|
||||
/// Alle Kandidaten, vom besten zum schlechtesten.
|
||||
///
|
||||
/// **Warum mehrere und nicht einer.** Eine Folge hat selten ein eigenes
|
||||
/// Querformatbild, die Staffel manchmal, die Serie fast immer. Bisher
|
||||
/// wurde die erste Adresse genommen, die sich **zusammensetzen** liess —
|
||||
/// und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus.
|
||||
/// Gab es keines, blieb die Fläche grau, obwohl eine Ebene höher eines
|
||||
/// gelegen hätte. Genau so sahen Folgen im Aufmacher aus.
|
||||
///
|
||||
/// Die Reihenfolge steht hier, ausprobiert wird sie beim Laden.
|
||||
static func backdropURLs(
|
||||
for item: MediaItem,
|
||||
backend: any MediaBackend,
|
||||
placement: Placement = .carousel,
|
||||
compact: Bool
|
||||
) -> [URL] {
|
||||
let size = size(placement, compact: compact)
|
||||
let pfade: [String?] = isPortrait(placement, compact: compact)
|
||||
// Hochkant: das Plakat der Folge, dann das der Staffel, dann das
|
||||
// der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist quer
|
||||
// und verliert hochkant die Hälfte.
|
||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||
|
||||
var gesehen = Set<String>()
|
||||
return pfade
|
||||
.compactMap(\.self)
|
||||
.filter { gesehen.insert($0).inserted }
|
||||
.compactMap { backend.artworkURL(path: $0, width: size.width, height: size.height) }
|
||||
}
|
||||
|
||||
/// Der freigestellte Schriftzug — ohne Bild-Transcoder.
|
||||
///
|
||||
/// Er ist ein PNG mit durchsichtigem Grund, und der Transcoder liefert
|
||||
/// JPEG. Aus dem Schriftzug würde sonst ein schwarzer Kasten.
|
||||
static func logoURL(for item: MediaItem, backend: any MediaBackend) -> URL? {
|
||||
backend.rawImageURL(path: item.clearLogoPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user