Der Aufmacher zeigte monatelang denselben Film
Er war nun einmal der erste in "Weiterschauen", und genommen wurden schlicht die ersten fünf der Liste. Jetzt wird gezogen — aber nicht bei jedem Blick: der Zufall hängt an einem Zeitfenster von sechs Stunden. Wer die App dreimal am Abend öffnet, findet sich zurecht; wer am nächsten Morgen wiederkommt, sieht etwas anderes. Titel ohne Hintergrundbild sind dabei nicht ausgeschlossen, sondern ziehen ein schlechteres Los. Sie ganz auszusperren war zu hart — in einer Mediathek, in der wenige Titel eines tragen, blieben immer dieselben drei übrig. Dieselbe Regel im Web, damit beide nicht auseinanderlaufen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
daa836fd8c
commit
c3f9afdb07
@@ -148,11 +148,87 @@ struct HomeView: View {
|
||||
var seen = Set<String>()
|
||||
let unique = candidates.filter { seen.insert($0.id).inserted }
|
||||
|
||||
// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
||||
// Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei
|
||||
// denn, keiner hätte eines.
|
||||
let withArtwork = unique.filter { $0.art != nil || $0.seriesArt != nil }
|
||||
return Array((withArtwork.isEmpty ? unique : withArtwork).prefix(layout.heroCount))
|
||||
// **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)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
|
||||
+25
-1
@@ -610,7 +610,7 @@ async function renderHome() {
|
||||
...views.map((v, i) => ({ title: `Neu in ${v.Name}`, items: latest[i] || [] })),
|
||||
].filter((r) => r.items.length);
|
||||
|
||||
const featured = [...resume, ...(latest[0] || [])].filter((i) => hasImg(i, 'Backdrop')).slice(0, 5);
|
||||
const featured = pickFeatured([...resume, ...next, ...latest.flat()]);
|
||||
|
||||
c.innerHTML = '';
|
||||
if (featured.length) c.appendChild(buildHero(featured));
|
||||
@@ -622,6 +622,30 @@ async function renderHome() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App.
|
||||
----------------------------------------------------------------------------
|
||||
Zwei Dinge auf einmal: die Auswahl wechselt alle sechs Stunden, und ein
|
||||
Titel ohne Hintergrundbild zieht ein schlechteres Los. Bei jedem Laden zu
|
||||
würfeln wäre unruhig — man tippt auf etwas, geht zurück, und es ist weg;
|
||||
einmal täglich wäre zu träge. */
|
||||
function pickFeatured(items) {
|
||||
// Ein Startwert, der sich alle sechs Stunden ändert. Derselbe für alle
|
||||
// Aufrufe im selben Fenster, damit die Auswahl beim Zurückblättern steht.
|
||||
let seed = Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761 % 2147483647;
|
||||
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||||
|
||||
const seen = new Set();
|
||||
return items
|
||||
.filter((it) => !seen.has(it.Id) && seen.add(it.Id))
|
||||
// Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus
|
||||
// [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt
|
||||
// es wenige, hat er seine Chance.
|
||||
.map((it) => ({ it, key: rnd() * (hasImg(it, 'Backdrop') ? 1 : 0.2) }))
|
||||
.sort((a, b) => b.key - a.key)
|
||||
.slice(0, 5)
|
||||
.map((x) => x.it);
|
||||
}
|
||||
|
||||
/* Der Aufmacher wechselt von selbst, wie in der App — übergeblendet, nicht
|
||||
durchgeblättert, damit keine Naht wandert. */
|
||||
function buildHero(items) {
|
||||
|
||||
Reference in New Issue
Block a user