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:
co-authored by
Claude Opus 5
parent
4b084bc926
commit
2a2afa8573
@@ -8,7 +8,18 @@ import SwiftUI
|
||||
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
|
||||
/// Aufmacher und die Hälfte der Plakate blieben grau.
|
||||
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
|
||||
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
|
||||
/// aus dem Seitenverhältnis auszurechnen.
|
||||
@@ -33,7 +44,24 @@ public struct FlixyArtwork: View {
|
||||
fallbackSymbol: String = "film",
|
||||
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.fillsFrame = fillsFrame
|
||||
self.fallbackSymbol = fallbackSymbol
|
||||
@@ -49,7 +77,7 @@ public struct FlixyArtwork: View {
|
||||
content
|
||||
}
|
||||
.clipped()
|
||||
.task(id: url) { await load() }
|
||||
.task(id: urls) { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -69,8 +97,12 @@ public struct FlixyArtwork: View {
|
||||
/// nicht — das kostet immer einen Durchlauf.
|
||||
private var available: PlatformImage? {
|
||||
if let loaded { return loaded }
|
||||
guard let url else { return nil }
|
||||
return ArtworkLoader.cachedImage(for: url)
|
||||
// Die erste, die schon im Speicher liegt — in der Reihenfolge der
|
||||
// Vorlieben, nicht in der des Zufalls.
|
||||
for url in urls {
|
||||
if let bild = ArtworkLoader.cachedImage(for: url) { return bild }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -92,7 +124,7 @@ public struct FlixyArtwork: View {
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let url else {
|
||||
guard !urls.isEmpty else {
|
||||
hasFailed = false
|
||||
loaded = nil
|
||||
return
|
||||
@@ -100,7 +132,7 @@ public struct FlixyArtwork: View {
|
||||
|
||||
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
|
||||
// Nachladen.
|
||||
if ArtworkLoader.cachedImage(for: url) != nil {
|
||||
if available != nil {
|
||||
hasFailed = false
|
||||
return
|
||||
}
|
||||
@@ -108,12 +140,17 @@ public struct FlixyArtwork: View {
|
||||
loaded = nil
|
||||
hasFailed = false
|
||||
|
||||
let image = await ArtworkLoader.shared.image(for: url)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
loaded = image
|
||||
hasFailed = image == nil
|
||||
// **Der Reihe nach, bis eine antwortet.** Genau das konnte die
|
||||
// frühere Fassung nicht: sie hatte nur eine Adresse und musste sich
|
||||
// vorher entscheiden, ohne zu wissen, ob es das Bild gibt.
|
||||
for url in urls {
|
||||
let bild = await ArtworkLoader.shared.image(for: url)
|
||||
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.
|
||||
private let metadataContent: AnyView?
|
||||
private let summary: String?
|
||||
private let artworkURL: URL?
|
||||
private let artworkURLs: [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.
|
||||
@@ -34,7 +34,7 @@ public struct HeroBanner<Actions: View>: View {
|
||||
metadata: String? = nil,
|
||||
metadataContent: AnyView? = nil,
|
||||
summary: String? = nil,
|
||||
artworkURL: URL?,
|
||||
artworkURLs: [URL],
|
||||
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
|
||||
@@ -48,7 +48,7 @@ public struct HeroBanner<Actions: View>: View {
|
||||
self.metadata = metadata
|
||||
self.metadataContent = metadataContent
|
||||
self.summary = summary
|
||||
self.artworkURL = artworkURL
|
||||
self.artworkURLs = artworkURLs
|
||||
self.logoURL = logoURL
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsAvailableSpace = fillsAvailableSpace
|
||||
@@ -127,7 +127,7 @@ public struct HeroBanner<Actions: View>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
FlixyArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user