From c3f9afdb07bad48d57093a1df98f65c491a3997c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 16:34:09 +0200 Subject: [PATCH] Der Aufmacher zeigte monatelang denselben Film MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Views/Home/HomeView.swift | 86 +++++++++++++++++++++++++++++++-- web/frontend/index.html | 26 +++++++++- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 6087319..414d7d2 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -148,11 +148,87 @@ struct HomeView: View { var seen = Set() 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 { diff --git a/web/frontend/index.html b/web/frontend/index.html index 460ddcc..9f8d2c6 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -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) {