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,302 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Das Aufmacher-Bild oben auf der Startseite.
|
||||
///
|
||||
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
|
||||
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
|
||||
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
|
||||
public struct HeroBanner<Actions: View>: View {
|
||||
private let title: String
|
||||
private let metadata: String?
|
||||
/// Eine Zeile, in der einzelne Teile anklickbar sind.
|
||||
///
|
||||
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
|
||||
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
|
||||
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
|
||||
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
|
||||
/// durch jeden Aufruf.
|
||||
private let metadataContent: AnyView?
|
||||
private let summary: String?
|
||||
private let artworkURLs: [URL]
|
||||
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
|
||||
///
|
||||
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
|
||||
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
|
||||
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
|
||||
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
|
||||
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
|
||||
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
|
||||
/// steht.
|
||||
///
|
||||
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
|
||||
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
|
||||
/// schwächer werden.
|
||||
private let outgoingArtworkURLs: [URL]
|
||||
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
||||
private let crossfade: CGFloat
|
||||
/// Wie weit das neue Bild dabei hereinrutscht.
|
||||
private let incomingOffset: CGFloat
|
||||
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
|
||||
///
|
||||
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
|
||||
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
|
||||
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
|
||||
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
|
||||
/// Aufmacher.
|
||||
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
|
||||
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
||||
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
||||
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
||||
private let logoURL: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
||||
private let fillsAvailableSpace: Bool
|
||||
private let actions: Actions
|
||||
|
||||
/// Der geladene Schriftzug — **mit der Adresse, zu der er gehört**.
|
||||
///
|
||||
/// **Ohne die Adresse wird er falsch.** Seit der Aufmacher seine
|
||||
/// Identität über den Titelwechsel hinweg behält, überlebt auch dieser
|
||||
/// Zustand ihn: der Schriftzug des vorigen Films stand dann über dem Bild
|
||||
/// des neuen, bis der neue geladen war. „Werner Beinhart" über John Wick.
|
||||
///
|
||||
/// Ein Zustand, der eine andere Angabe zwischenspeichert, muss sagen,
|
||||
/// wozu er gehört — sonst gilt er für alles.
|
||||
@State private var loadedLogo: (url: URL, image: PlatformImage)?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
metadata: String? = nil,
|
||||
metadataContent: AnyView? = nil,
|
||||
summary: String? = nil,
|
||||
artworkURLs: [URL],
|
||||
outgoingArtworkURLs: [URL] = [],
|
||||
outgoingTitle: String? = nil,
|
||||
outgoingMetadata: String? = nil,
|
||||
outgoingLogoURL: URL? = nil,
|
||||
crossfade: CGFloat = 1,
|
||||
incomingOffset: CGFloat = 0,
|
||||
logoURL: URL? = nil,
|
||||
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
|
||||
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
|
||||
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
||||
// sehen.
|
||||
aspectRatio: CGFloat = HaloAspectRatio.hero,
|
||||
fillsAvailableSpace: Bool = false,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.title = title
|
||||
self.metadata = metadata
|
||||
self.metadataContent = metadataContent
|
||||
self.summary = summary
|
||||
self.artworkURLs = artworkURLs
|
||||
self.outgoingArtworkURLs = outgoingArtworkURLs
|
||||
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
|
||||
self.crossfade = crossfade
|
||||
self.incomingOffset = incomingOffset
|
||||
self.logoURL = logoURL
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsAvailableSpace = fillsAvailableSpace
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
/// Wie stark Schrift und Schriftzug mitwachsen.
|
||||
///
|
||||
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
|
||||
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
|
||||
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
|
||||
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
|
||||
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
|
||||
/// Plakatschrift endet.
|
||||
private func scale(for width: CGFloat) -> CGFloat {
|
||||
min(max(width / 834, 1), 1.7)
|
||||
}
|
||||
|
||||
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
|
||||
///
|
||||
/// Ohne Wechsel ist es genau ein `HaloArtwork` wie zuvor: die zweite
|
||||
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
|
||||
@ViewBuilder
|
||||
private var bild: some View {
|
||||
if outgoingArtworkURLs.isEmpty {
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
} else {
|
||||
ZStack {
|
||||
HaloArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
|
||||
///
|
||||
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
|
||||
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
|
||||
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
|
||||
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
|
||||
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
|
||||
/// zwei Bewegungen, wo eine sein soll.
|
||||
///
|
||||
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
|
||||
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
|
||||
/// zum Aufmacher und macht mit, was der Aufmacher macht.
|
||||
///
|
||||
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
||||
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
||||
/// Kasten.
|
||||
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||
///
|
||||
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
||||
/// steht bis dahin der gesetzte Titel.
|
||||
private var availableLogo: PlatformImage? {
|
||||
guard let logoURL else { return nil }
|
||||
if let loadedLogo, loadedLogo.url == logoURL { return loadedLogo.image }
|
||||
return ArtworkLoader.cachedImage(for: logoURL)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
bild
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||
// erste Reihe übergeht, eine sichtbare Kante.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: Color.haloCanvas.opacity(0.75), location: 0.45),
|
||||
.init(color: Color.haloCanvas, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
|
||||
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
|
||||
// reicht. Über einem dunklen Aufmacher geht das gut; über
|
||||
// einem hellen verschwinden sie.
|
||||
//
|
||||
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
|
||||
// weit, wie die Insel baut, und läuft schnell aus. Ein
|
||||
// großzügigerer deckte das Bild zu, und genau das Bild ist der
|
||||
// Grund, warum es randlos sein soll.
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.45), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 150)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
GeometryReader { geometry in
|
||||
let scale = scale(for: geometry.size.width)
|
||||
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// **Der weichende Text bleibt stehen, bis der neue
|
||||
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
|
||||
// keiner da: der neue blendet ein, der alte wäre
|
||||
// schon weg. Er liegt über dem Verlauf — dort kostet
|
||||
// er die Abdunklung nichts.
|
||||
if let outgoing, crossfade < 1 {
|
||||
inhalt(
|
||||
title: outgoing.title,
|
||||
metadata: outgoing.metadata,
|
||||
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
|
||||
scale: scale
|
||||
)
|
||||
}
|
||||
|
||||
// Der neue — mit demselben Einblenden und demselben
|
||||
// Hereinrutschen wie das Bild darunter, damit beides
|
||||
// als eine Bewegung erscheint.
|
||||
inhalt(
|
||||
title: title,
|
||||
metadata: metadata,
|
||||
logo: availableLogo,
|
||||
scale: scale
|
||||
)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
.frame(
|
||||
width: geometry.size.width,
|
||||
height: geometry.size.height,
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.task(id: logoURL) {
|
||||
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||
if let bild = await ArtworkLoader.shared.image(for: logoURL) {
|
||||
loadedLogo = (logoURL, bild)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
|
||||
///
|
||||
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
|
||||
/// einmal für den weichenden und einmal für den kommenden Eintrag.
|
||||
@ViewBuilder
|
||||
private func inhalt(
|
||||
title: String,
|
||||
metadata: String?,
|
||||
logo: PlatformImage?,
|
||||
scale: CGFloat
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug * scale) {
|
||||
if let logo {
|
||||
Image(platform: logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
// Nach oben begrenzt: manche Schriftzüge sind fast
|
||||
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
||||
// hohes Logo die Knöpfe aus dem Bild.
|
||||
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
||||
.accessibilityLabel(Text(title))
|
||||
} else {
|
||||
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
||||
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
||||
// eine Systemschrift.
|
||||
Text(title)
|
||||
.font(.system(size: 34 * scale, weight: .bold))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
if let metadataContent {
|
||||
metadataContent
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else if let metadata {
|
||||
Text(metadata)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(2)
|
||||
// Ein Satz über die ganze Breite eines großen Fensters ist
|
||||
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
|
||||
// sind die Grenze, ab der Satzspiegel unangenehm werden.
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
actions
|
||||
}
|
||||
.padding(.top, HaloSpacing.tight)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin * scale)
|
||||
.padding(.bottom, HaloSpacing.loose * scale)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user