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
+9 -72
View File
@@ -150,84 +150,21 @@ struct HomeView: View {
// **Gemischt, aber nicht bei jedem Blick.**
//
// Vorher standen hier schlicht die ersten fünf der Liste. Das ist
// deterministisch, und deshalb zeigte der Aufmacher monatelang
// denselben Film — er war nun einmal der erste in „Weiterschauen".
//
// Bei jedem Laden zu würfeln wäre das andere Extrem: man tippt auf
// etwas, geht zurück, und es ist weg. Deshalb ein Zufall mit
// 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)
// Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
// Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
// hätte: das Los hing an der **Position** in der Liste, nicht am
// Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
// alle dahinter ein anderes Los — und der Aufmacher war nach jedem
// Start ein anderer, obwohl das Zeitfenster dasselbe war.
switch layout.heroSource {
case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander.
let continuing = unique.filter { $0.watchProgress != nil }
let rest = unique.filter { $0.watchProgress == nil }
let ordered = Self.drawn(continuing, using: &generator)
+ Self.drawn(rest, using: &generator)
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
return Array(ordered.prefix(layout.heroCount))
case .latestOnly:
return Array(Self.drawn(unique, using: &generator).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)
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
}
}