diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 7ac225a..88afaf7 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { "extractionState": "stale", "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": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 80b9252..3157192 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -77,9 +77,9 @@ struct PlayableDetailView: View { // hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war // davon ein Streifen aus der Mitte. Derselbe Fehler war im // Aufmacher schon einmal behoben worden, nur hier nicht. - artworkURL: session.backend.flatMap { - HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) - }, + artworkURLs: session.backend.map { + HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) + } ?? [], logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { Button { diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 07e4791..7e29ba8 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -61,9 +61,9 @@ struct SeriesDetailView: View { // hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war // davon ein Streifen aus der Mitte. Derselbe Fehler war im // Aufmacher schon einmal behoben worden, nur hier nicht. - artworkURL: session.backend.flatMap { - HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) - }, + artworkURLs: session.backend.map { + HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) + } ?? [], logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { DownloadAllButton(container: item) diff --git a/Flixy/Views/Home/HeroArtwork.swift b/Flixy/Views/Home/HeroArtwork.swift index d754b5e..8a29991 100644 --- a/Flixy/Views/Home/HeroArtwork.swift +++ b/Flixy/Views/Home/HeroArtwork.swift @@ -36,11 +36,39 @@ enum HeroArtwork { /// vom Bild bleibt wenig übrig. Ein Plakat ist von vornherein hochkant; /// es passt in den Rahmen, ohne dass etwas weggeschnitten werden muss. 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 path = compact - ? (item.posterArtworkPath ?? item.art) - : (item.art ?? item.seriesArt ?? item.thumb) - return backend.artworkURL(path: path, width: size.width, height: size.height) + let pfade: [String?] = compact + // Hochkant: das Plakat der Folge, dann das der Staffel, dann das + // der Serie. Ein Hintergrundbild kommt hier zuletzt — es ist + // 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() + 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. diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 698d607..5f3ec4a 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -59,6 +59,10 @@ struct HeroCarousel: View { /// In welche Richtung zuletzt gewechselt wurde. Wer zurückblättert, soll /// das Bild auch von der anderen Seite hereinkommen sehen. @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(\.flixyAccent) private var accent @@ -151,6 +155,12 @@ struct HeroCarousel: View { // eine Geste, die schon bei null Punkten greift, das Scrollen // schluckt. .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 } } @@ -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 { - HStack(spacing: FlixySpacing.snug) { + HStack(spacing: isEnlarged ? FlixySpacing.regular : FlixySpacing.snug) { ForEach(items.indices, id: \.self) { position in Capsule() .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.2), value: isEnlarged) .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. private func step(by direction: Int) { guard items.count > 1 else { return } diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index ed301e4..bcdebc4 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -16,9 +16,9 @@ struct HeroSection: View { title: item.rowTitle, metadata: metadata, summary: item.summary, - artworkURL: session.backend.flatMap { - HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) - }, + artworkURLs: session.backend.map { + HeroArtwork.backdropURLs(for: item, backend: $0, compact: sizeClass == .compact) + } ?? [], logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) }, // Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass // Schriftzug und Beschreibung übereinanderlagen. diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 414d7d2..5310878 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -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)) } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift index 7393c0c..093f730 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift @@ -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 } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index ee56494..4184aad 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -17,7 +17,7 @@ public struct HeroBanner: 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: 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: 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: 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 diff --git a/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift new file mode 100644 index 0000000..762b8ea --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/HeroSelection.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift new file mode 100644 index 0000000..5ccd7bd --- /dev/null +++ b/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift @@ -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) + } + } +}