Der Aufmacher bleibt stehen, und Folgen bekommen ein Bild

Drei Sachen am Karussell.

Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein
Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position
in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder
aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und
„Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der
Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests,
weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann.

Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie
hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es
das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge
wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine
Adresse antwortet.

Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger
ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und
auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man
ohnehin, und zwei Knoepfe staenden nur im Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 20:46:34 +02:00
co-authored by Claude Opus 5
parent 4b084bc926
commit 2a2afa8573
11 changed files with 427 additions and 104 deletions
+34
View File
@@ -1873,6 +1873,23 @@
} }
} }
}, },
"hero.next": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nächster Vorschlag"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next suggestion"
}
}
}
},
"hero.play": { "hero.play": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -1890,6 +1907,23 @@
} }
} }
}, },
"hero.previous": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Voriger Vorschlag"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous suggestion"
}
}
}
},
"hero.resume": { "hero.resume": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
+3 -3
View File
@@ -77,9 +77,9 @@ struct PlayableDetailView: View {
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war // hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
// davon ein Streifen aus der Mitte. Derselbe Fehler war im // davon ein Streifen aus der Mitte. Derselbe Fehler war im
// Aufmacher schon einmal behoben worden, nur hier nicht. // Aufmacher schon einmal behoben worden, nur hier nicht.
artworkURL: session.backend.flatMap { artworkURLs: session.backend.map {
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
}, } ?? [],
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) { ) {
Button { Button {
+3 -3
View File
@@ -61,9 +61,9 @@ struct SeriesDetailView: View {
// hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war // hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war
// davon ein Streifen aus der Mitte. Derselbe Fehler war im // davon ein Streifen aus der Mitte. Derselbe Fehler war im
// Aufmacher schon einmal behoben worden, nur hier nicht. // Aufmacher schon einmal behoben worden, nur hier nicht.
artworkURL: session.backend.flatMap { artworkURLs: session.backend.map {
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
}, } ?? [],
logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) logoURL: session.backend?.rawImageURL(path: item.clearLogoPath)
) { ) {
DownloadAllButton(container: item) DownloadAllButton(container: item)
+32 -4
View File
@@ -36,11 +36,39 @@ enum HeroArtwork {
/// vom Bild bleibt wenig übrig. Ein Plakat ist von vornherein hochkant; /// vom Bild bleibt wenig übrig. Ein Plakat ist von vornherein hochkant;
/// es passt in den Rahmen, ohne dass etwas weggeschnitten werden muss. /// es passt in den Rahmen, ohne dass etwas weggeschnitten werden muss.
static func backdropURL(for item: MediaItem, backend: any MediaBackend, compact: Bool) -> URL? { static func backdropURL(for item: MediaItem, backend: any MediaBackend, compact: Bool) -> URL? {
backdropURLs(for: item, backend: backend, 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,
compact: Bool
) -> [URL] {
let size = size(compact: compact) let size = size(compact: compact)
let path = compact let pfade: [String?] = compact
? (item.posterArtworkPath ?? item.art) // Hochkant: das Plakat der Folge, dann das der Staffel, dann das
: (item.art ?? item.seriesArt ?? item.thumb) // der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist
return backend.artworkURL(path: path, width: size.width, height: size.height) // 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. /// Der freigestellte Schriftzug — ohne Bild-Transcoder.
+94 -2
View File
@@ -59,6 +59,10 @@ struct HeroCarousel: View {
/// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll /// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll
/// das Bild auch von der anderen Seite hereinkommen sehen. /// das Bild auch von der anderen Seite hereinkommen sehen.
@State private var isMovingForward = true @State private var isMovingForward = true
/// Ob der Zeiger über dem Aufmacher liegt — dann erscheinen die Pfeile.
@State private var isPointerInside = false
/// Ob er über den Punkten liegt — dann werden sie gross genug zum Treffen.
@State private var isHoveringDots = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@@ -151,6 +155,12 @@ struct HeroCarousel: View {
// eine Geste, die schon bei null Punkten greift, das Scrollen // eine Geste, die schon bei null Punkten greift, das Scrollen
// schluckt. // schluckt.
.simultaneousGesture(TapGesture().onEnded { isAdvancing = false }) .simultaneousGesture(TapGesture().onEnded { isAdvancing = false })
// **Nur auf dem Mac.** Dort gibt es einen Zeiger, und Wischen ist
// mit dem Trackpad zwar möglich, aber nicht naheliegend — man
// sieht dem Aufmacher nicht an, dass er weitergeht. Auf dem iPad
// und dem iPhone wischt man ohnehin, und zwei Knöpfe ständen nur
// im Bild.
.overlay { if isMac, items.count > 1 { arrows } }
if items.count > 1 { indicator } if items.count > 1 { indicator }
} }
@@ -166,18 +176,100 @@ struct HeroCarousel: View {
} }
} }
private var isMac: Bool { FlixyPlatform.isMac }
/// Die beiden Pfeile links und rechts.
///
/// Zurückhaltend: sie sollen im Bild liegen, ohne es zu beherrschen.
/// Deshalb erscheinen sie erst, wenn der Zeiger über dem Aufmacher ist —
/// vorher gibt es nichts zu bedienen, und ein Knopf, der dauerhaft über
/// einem Filmplakat klebt, ist ein Fremdkörper.
private var arrows: some View {
HStack {
arrow(symbol: "chevron.left", direction: -1)
Spacer()
arrow(symbol: "chevron.right", direction: 1)
}
.padding(.horizontal, FlixySpacing.regular)
.opacity(isPointerInside ? 1 : 0)
.animation(.easeOut(duration: 0.2), value: isPointerInside)
.onContinuousHover { phase in
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
}
}
private func arrow(symbol: String, direction: Int) -> some View {
Button {
isAdvancing = false
step(by: direction)
} label: {
Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.padding(FlixySpacing.regular)
.background(.black.opacity(0.45), in: .circle)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(direction > 0 ? "hero.next" : "hero.previous"))
}
/// Die Punkte darunter — auf dem Mac auch ein Bedienelement.
///
/// **Warum sie wachsen.** Sechs Punkte hoch trifft niemand, und drei
/// Punkte breit schon gar nicht. Unter dem Zeiger werden sie deshalb
/// grösser, sobald er in ihrer Nähe ist — dieselbe Bewegung, mit der das
/// iPad seine Fensterknöpfe unter dem Finger vergrössert. Dass etwas
/// wächst, wenn man sich nähert, heisst: hier kann man etwas tun.
///
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
private var indicator: some View { private var indicator: some View {
HStack(spacing: FlixySpacing.snug) { HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) {
ForEach(items.indices, id: \.self) { position in ForEach(items.indices, id: \.self) { position in
Capsule() Capsule()
.fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4)) .fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4))
.frame(width: position == currentIndex ? 18 : 6, height: 6) .frame(
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
height: isEnlarged ? 12 : 6
)
.contentShape(.rect)
.onTapGesture {
guard isMac else { return }
isAdvancing = false
jump(to: position)
}
} }
} }
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
// beim Annähern etwas bewirken, nicht erst beim Treffen.
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.onContinuousHover { phase in
guard isMac else { return }
if case .active = phase { isHoveringDots = true } else { isHoveringDots = false }
}
.animation(.snappy(duration: 0.25), value: currentIndex) .animation(.snappy(duration: 0.25), value: currentIndex)
.animation(.snappy(duration: 0.2), value: isEnlarged)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
private var isEnlarged: Bool { isMac && isHoveringDots }
/// Springt unmittelbar zu einem Titel.
private func jump(to position: Int) {
guard position != currentIndex, items.indices.contains(position) else { return }
isMovingForward = position > currentIndex
fadingID = shownID
shownID = items[position].id
cover = 0
withAnimation(.easeInOut(duration: 0.55)) {
cover = 1
} completion: {
fadingID = nil
}
}
/// Einen Titel weiter oder zurück. /// Einen Titel weiter oder zurück.
private func step(by direction: Int) { private func step(by direction: Int) {
guard items.count > 1 else { return } guard items.count > 1 else { return }
+3 -3
View File
@@ -16,9 +16,9 @@ struct HeroSection: View {
title: item.rowTitle, title: item.rowTitle,
metadata: metadata, metadata: metadata,
summary: item.summary, summary: item.summary,
artworkURL: session.backend.flatMap { artworkURLs: session.backend.map {
HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact)
}, } ?? [],
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) }, logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass // Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
// Schriftzug und Beschreibung übereinanderlagen. // Schriftzug und Beschreibung übereinanderlagen.
+9 -72
View File
@@ -150,84 +150,21 @@ struct HomeView: View {
// **Gemischt, aber nicht bei jedem Blick.** // **Gemischt, aber nicht bei jedem Blick.**
// //
// Vorher standen hier schlicht die ersten fünf der Liste. Das ist // Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
// deterministisch, und deshalb zeigte der Aufmacher monatelang // Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
// denselben Film — er war nun einmal der erste in „Weiterschauen". // hätte: das Los hing an der **Position** in der Liste, nicht am
// // Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
// Bei jedem Laden zu würfeln wäre das andere Extrem: man tippt auf // alle dahinter ein anderes Los — und der Aufmacher war nach jedem
// etwas, geht zurück, und es ist weg. Deshalb ein Zufall mit // Start ein anderer, obwohl das Zeitfenster dasselbe war.
// Zeitfenster — innerhalb von sechs Stunden dieselbe Auswahl, danach
// eine neue. Wer die App dreimal am Abend öffnet, findet sich zurecht;
// wer sie am nächsten Morgen öffnet, sieht etwas anderes.
var generator = TimeWindowGenerator(hours: 6)
switch layout.heroSource { switch layout.heroSource {
case .continueFirst: case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg: // Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander. // Angefangenes bleibt vorn, wechselt aber untereinander.
let continuing = unique.filter { $0.watchProgress != nil } let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
let rest = unique.filter { $0.watchProgress == nil } + HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
let ordered = Self.drawn(continuing, using: &generator)
+ Self.drawn(rest, using: &generator)
return Array(ordered.prefix(layout.heroCount)) return Array(ordered.prefix(layout.heroCount))
case .latestOnly: case .latestOnly:
return Array(Self.drawn(unique, using: &generator).prefix(layout.heroCount)) return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
}
}
/// Zieht die Titel in eine Reihenfolge — mit ungleichen Losen.
///
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text, und
/// ein Titel ohne eines gehört dort eigentlich nicht hin. Ihn ganz
/// auszuschließen war aber zu hart: bei einer Mediathek, in der wenige
/// Titel ein Hintergrundbild tragen, blieben immer dieselben drei übrig.
///
/// Also bekommt jeder ein Los, und wer kein Hintergrundbild hat, ein
/// schlechteres — sein Wert liegt nur im untersten Fünftel. Solange es
/// genug Titel mit Bild gibt, kommt er praktisch nie nach vorn; gibt es
/// wenige, hat er seine Chance. Kein Sonderfall, keine Schwelle, ab der
/// die Regel kippt.
private static func drawn(
_ items: [MediaItem],
using generator: inout TimeWindowGenerator
) -> [MediaItem] {
var keyed: [(item: MediaItem, key: Double)] = []
keyed.reserveCapacity(items.count)
for item in items {
let hasBackdrop = item.art != nil || item.seriesArt != nil
let weight = hasBackdrop ? 1.0 : 0.2
keyed.append((item, Double.random(in: 0..<1, using: &generator) * weight))
}
return keyed.sorted { $0.key > $1.key }.map(\.item)
}
/// Ein Zufall, der sich innerhalb eines Zeitfensters gleich verhält.
///
/// Derselbe Startwert für alle Aufrufe im selben Fenster — damit ist die
/// Auswahl stabil, solange man die App benutzt, und trotzdem eine andere,
/// wenn man später wiederkommt. Ohne diesen Umweg müsste die gewählte
/// Reihe irgendwo gespeichert und wieder eingelesen werden, nur damit sie
/// beim Zurückblättern nicht springt.
///
/// SplitMix64: kurz, ohne Abhängigkeit, und für das Mischen von fünf
/// Kacheln mehr als gut genug. Für irgendetwas Sicherheitsrelevantes wäre
/// er es nicht — das ist hier auch nicht die Frage.
private struct TimeWindowGenerator: RandomNumberGenerator {
private var state: UInt64
init(hours: Double, now: Date = .now) {
let window = UInt64(max(0, now.timeIntervalSince1970 / (hours * 3_600)))
state = window &* 0x9E37_79B9_7F4A_7C15
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
} }
} }
@@ -8,7 +8,18 @@ import SwiftUI
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der /// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
/// Aufmacher und die Hälfte der Plakate blieben grau. /// Aufmacher und die Hälfte der Plakate blieben grau.
public struct FlixyArtwork: View { public struct FlixyArtwork: View {
private let url: URL? /// Die Adressen, der Reihe nach probiert.
///
/// **Warum mehrere.** 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 das
/// Staffelbild nicht, blieb die Fläche grau, und der Weg zur Serie wurde
/// nie versucht.
///
/// Die Reihenfolge gehört deshalb hierher, ins Laden, nicht ins
/// Zusammensetzen: erst wer gefragt hat, weiss, ob es etwas gibt.
private let urls: [URL]
private let aspectRatio: CGFloat private let aspectRatio: CGFloat
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe /// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
/// aus dem Seitenverhältnis auszurechnen. /// aus dem Seitenverhältnis auszurechnen.
@@ -33,7 +44,24 @@ public struct FlixyArtwork: View {
fallbackSymbol: String = "film", fallbackSymbol: String = "film",
contentMode: ContentMode = .fill contentMode: ContentMode = .fill
) { ) {
self.url = url self.init(
urls: [url].compactMap(\.self),
aspectRatio: aspectRatio,
fillsFrame: fillsFrame,
fallbackSymbol: fallbackSymbol,
contentMode: contentMode
)
}
/// Mehrere Adressen, von der besten zur schlechtesten.
public init(
urls: [URL],
aspectRatio: CGFloat,
fillsFrame: Bool = false,
fallbackSymbol: String = "film",
contentMode: ContentMode = .fill
) {
self.urls = urls
self.aspectRatio = aspectRatio self.aspectRatio = aspectRatio
self.fillsFrame = fillsFrame self.fillsFrame = fillsFrame
self.fallbackSymbol = fallbackSymbol self.fallbackSymbol = fallbackSymbol
@@ -49,7 +77,7 @@ public struct FlixyArtwork: View {
content content
} }
.clipped() .clipped()
.task(id: url) { await load() } .task(id: urls) { await load() }
} }
@ViewBuilder @ViewBuilder
@@ -69,8 +97,12 @@ public struct FlixyArtwork: View {
/// nicht — das kostet immer einen Durchlauf. /// nicht — das kostet immer einen Durchlauf.
private var available: PlatformImage? { private var available: PlatformImage? {
if let loaded { return loaded } if let loaded { return loaded }
guard let url else { return nil } // Die erste, die schon im Speicher liegt — in der Reihenfolge der
return ArtworkLoader.cachedImage(for: url) // Vorlieben, nicht in der des Zufalls.
for url in urls {
if let bild = ArtworkLoader.cachedImage(for: url) { return bild }
}
return nil
} }
@ViewBuilder @ViewBuilder
@@ -92,7 +124,7 @@ public struct FlixyArtwork: View {
} }
private func load() async { private func load() async {
guard let url else { guard !urls.isEmpty else {
hasFailed = false hasFailed = false
loaded = nil loaded = nil
return return
@@ -100,7 +132,7 @@ public struct FlixyArtwork: View {
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das // Was schon da ist, zeigt `available` bereits — hier bleibt nur das
// Nachladen. // Nachladen.
if ArtworkLoader.cachedImage(for: url) != nil { if available != nil {
hasFailed = false hasFailed = false
return return
} }
@@ -108,12 +140,17 @@ public struct FlixyArtwork: View {
loaded = nil loaded = nil
hasFailed = false hasFailed = false
let image = await ArtworkLoader.shared.image(for: url) // **Der Reihe nach, bis eine antwortet.** Genau das konnte die
guard !Task.isCancelled else { return } // frühere Fassung nicht: sie hatte nur eine Adresse und musste sich
// vorher entscheiden, ohne zu wissen, ob es das Bild gibt.
withAnimation(.easeOut(duration: 0.2)) { for url in urls {
loaded = image let bild = await ArtworkLoader.shared.image(for: url)
hasFailed = image == nil guard !Task.isCancelled else { return }
if let bild {
withAnimation(.easeOut(duration: 0.2)) { loaded = bild }
return
}
} }
hasFailed = true
} }
} }
@@ -17,7 +17,7 @@ public struct HeroBanner<Actions: View>: View {
/// durch jeden Aufruf. /// durch jeden Aufruf.
private let metadataContent: AnyView? private let metadataContent: AnyView?
private let summary: String? private let summary: String?
private let artworkURL: URL? private let artworkURLs: [URL]
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt /// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner /// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht. /// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
@@ -34,7 +34,7 @@ public struct HeroBanner<Actions: View>: View {
metadata: String? = nil, metadata: String? = nil,
metadataContent: AnyView? = nil, metadataContent: AnyView? = nil,
summary: String? = nil, summary: String? = nil,
artworkURL: URL?, artworkURLs: [URL],
logoURL: URL? = nil, logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier // 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts // 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
@@ -48,7 +48,7 @@ public struct HeroBanner<Actions: View>: View {
self.metadata = metadata self.metadata = metadata
self.metadataContent = metadataContent self.metadataContent = metadataContent
self.summary = summary self.summary = summary
self.artworkURL = artworkURL self.artworkURLs = artworkURLs
self.logoURL = logoURL self.logoURL = logoURL
self.aspectRatio = aspectRatio self.aspectRatio = aspectRatio
self.fillsAvailableSpace = fillsAvailableSpace self.fillsAvailableSpace = fillsAvailableSpace
@@ -127,7 +127,7 @@ public struct HeroBanner<Actions: View>: View {
} }
public var body: some View { public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace) FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in // Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die // Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
@@ -0,0 +1,84 @@
import Foundation
/// Welche Titel oben gross gezeigt werden — und in welcher Reihenfolge.
///
/// **Warum es das als eigenen Typ gibt.** Die Auswahl soll sich zufällig
/// anfühlen und trotzdem stabil sein: dreimal am Abend dieselbe, am nächsten
/// Morgen eine andere. Das ist genau die Art Regel, die man nicht im
/// laufenden Betrieb beurteilen kann — „es wechselt zu oft" ist kein Befund,
/// den man debuggen könnte. Mit einer Liste hinein und einer heraus schon.
///
/// **Der Fehler, der es hierher gebracht hat.** Vorher wurde ein Zufallszahl-
/// erzeuger einmal je Zeitfenster aufgesetzt und dann Titel für Titel
/// abgefragt. Das Los hing damit an der **Position** in der Liste: kam ein
/// Titel dazu, verschwand einer, oder änderte sich der Fortschritt, bekamen
/// alle dahinter ein anderes Los — und der Aufmacher war nach jedem Neustart
/// ein anderer, obwohl das Zeitfenster dasselbe war.
///
/// Jetzt hängt das Los an der **Kennung** des Titels. Wer dazukommt, stört
/// niemanden; wer geht, hinterlässt eine Lücke und sonst nichts.
public enum HeroSelection {
/// Wie lange dieselbe Auswahl gilt.
public static let windowHours: Double = 6
/// Ordnet die Titel, ohne die Reihenfolge fest vorzugeben.
///
/// **Ungleiche Lose.** Ohne Hintergrundbild ist der Aufmacher eine graue
/// Fläche mit Text, und ein Titel ohne eines gehört dort eigentlich nicht
/// hin. Ihn auszuschliessen wäre aber zu hart: bei einer Mediathek, in
/// der wenige Titel eines tragen, blieben immer dieselben drei übrig.
/// Also bekommt er ein schlechteres Los statt gar keines.
public static func ordered(
_ items: [MediaItem],
at time: Date = .now,
windowHours: Double = windowHours
) -> [MediaItem] {
let fenster = UInt64(max(0, time.timeIntervalSince1970 / (windowHours * 3_600)))
var bewertet: [(item: MediaItem, los: Double)] = []
bewertet.reserveCapacity(items.count)
for item in items {
bewertet.append((item, lot(for: item, window: fenster)))
}
// Bei gleichem Los die Kennung: zweimal dieselbe Eingabe soll zweimal
// dasselbe ergeben, sonst ist ein Fehler nicht wiederholbar.
bewertet.sort { links, rechts in
links.los == rechts.los ? links.item.id < rechts.item.id : links.los > rechts.los
}
return bewertet.map(\.item)
}
/// Das Los eines Titels in diesem Zeitfenster — zwischen 0 und 1.
static func lot(for item: MediaItem, window: UInt64) -> Double {
let hatBild = item.art != nil || item.seriesArt != nil
let gewicht = hatBild ? 1.0 : 0.2
return unitRandom(seed: stableHash(item.id) ^ window) * gewicht
}
/// Ein Streuwert, der über Programmläufe hinweg gleich bleibt.
///
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig;
/// damit wäre die Auswahl nach jedem Start eine andere — also genau der
/// Fehler, der hier behoben werden soll, nur an einer anderen Stelle.
static func stableHash(_ text: String) -> UInt64 {
// FNV-1a: vier Zeilen, keine Abhängigkeit, und für das Mischen von
// ein paar Kacheln mehr als gut genug.
var hash: UInt64 = 0xCBF2_9CE4_8422_2325
for byte in text.utf8 {
hash ^= UInt64(byte)
hash = hash &* 0x0000_0100_0000_01B3
}
return hash
}
/// SplitMix64, auf 0..<1 gebracht.
static func unitRandom(seed: UInt64) -> Double {
var z = seed &+ 0x9E37_79B9_7F4A_7C15
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
z = z ^ (z >> 31)
// Die oberen 53 Bit: genau die, die ein `Double` verlustfrei trägt.
return Double(z >> 11) / Double(1 << 53)
}
}
@@ -0,0 +1,111 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Auswahl für den Aufmacher")
struct HeroSelectionTests {
private func titel(_ id: String, backdrop: Bool = true) -> MediaItem {
MediaItem(id: id, kind: .movie, title: "Film \(id)", art: backdrop ? "/art/\(id)" : nil)
}
private var zwanzig: [MediaItem] { (1...20).map { titel(String($0)) } }
/// **Der Fehler, wegen dem es diesen Typ gibt.** Dieselbe Liste, dasselbe
/// Zeitfenster, zweimal gefragt — und zweimal dasselbe. Vorher kam das
/// nur heraus, solange die Liste sich nicht um einen einzigen Eintrag
/// unterschied.
@Test("Gleiche Eingabe, gleiches Ergebnis")
func stableWithinWindow() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
== HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
)
}
/// Und der eigentliche Punkt: ein Titel mehr darf die übrigen nicht
/// durcheinanderbringen. Genau das ist bei jedem Neustart passiert —
/// „Weiterschauen" wächst und schrumpft ständig.
@Test("Ein zusätzlicher Titel verschiebt die anderen nicht")
func addingOneKeepsTheRest() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
let nachher = HeroSelection.ordered(zwanzig + [titel("99")], at: jetzt).map(\.id)
// Der Neue darf irgendwo stehen; die übrigen müssen in derselben
// Reihenfolge zueinander stehen wie vorher.
#expect(nachher.filter { $0 != "99" } == vorher)
}
@Test("Ein fehlender Titel hinterlässt nur eine Lücke")
func removingOneKeepsTheRest() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
let ohne = HeroSelection.ordered(zwanzig.filter { $0.id != "7" }, at: jetzt).map(\.id)
#expect(ohne == vorher.filter { $0 != "7" })
}
/// Sechs Stunden später eine andere Auswahl — sonst wäre es keine
/// Abwechslung, sondern eine feste Reihenfolge.
@Test("Im nächsten Zeitfenster steht es anders")
func changesAcrossWindows() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let später = jetzt.addingTimeInterval(7 * 3_600)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
!= HeroSelection.ordered(zwanzig, at: später).map(\.id)
)
}
@Test("Eine Stunde später steht es noch gleich")
func stableAcrossAnHour() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
== HeroSelection.ordered(zwanzig, at: jetzt.addingTimeInterval(1_800)).map(\.id)
)
}
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
/// Ausschliessen wäre zu hart — schlechter losen ist genug.
@Test("Titel ohne Hintergrundbild landen meistens hinten")
func backdropsWin() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let gemischt = (1...10).map { titel("mit\($0)") }
+ (1...10).map { titel("ohne\($0)", backdrop: false) }
let ersteFünf = HeroSelection.ordered(gemischt, at: jetzt).prefix(5).map(\.id)
#expect(ersteFünf.allSatisfy { $0.hasPrefix("mit") })
}
/// Gäbe es nur Titel ohne Bild, dürfte nicht einfach nichts kommen.
@Test("Gibt es nur Titel ohne Bild, kommen sie trotzdem")
func withoutBackdropsStillReturns() {
let ohne = (1...5).map { titel(String($0), backdrop: false) }
#expect(HeroSelection.ordered(ohne).count == 5)
}
@Test("Eine leere Liste bleibt leer")
func emptyStaysEmpty() {
#expect(HeroSelection.ordered([]).isEmpty)
}
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig —
/// damit wäre die Auswahl nach jedem Start eine andere, also genau der
/// Fehler, der hier behoben werden soll.
@Test("Der Streuwert hängt nur am Text")
func hashIsStable() {
#expect(HeroSelection.stableHash("abc") == HeroSelection.stableHash("abc"))
#expect(HeroSelection.stableHash("abc") != HeroSelection.stableHash("abd"))
}
@Test("Die Lose liegen zwischen null und eins")
func lotsAreInRange() {
for id in 1...200 {
let los = HeroSelection.unitRandom(seed: UInt64(id))
#expect(los >= 0 && los < 1)
}
}
}