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>
241 lines
9.8 KiB
Swift
241 lines
9.8 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
|
struct HomeView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(HomeLayoutSettings.self) private var layout
|
|
@State private var state: LoadState<[MediaRowGroup]> = .loading
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch state {
|
|
case .loading:
|
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
case .failed(let message):
|
|
ContentUnavailableView {
|
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(message)
|
|
} actions: {
|
|
Button("action.retry") { Task { await load() } }
|
|
}
|
|
|
|
case .loaded(let allRows):
|
|
let hubs = layout.apply(to: prepared(allRows))
|
|
if hubs.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("home.empty.title", systemImage: "film.stack")
|
|
} description: {
|
|
Text("home.empty.description")
|
|
}
|
|
} else {
|
|
content(hubs: hubs)
|
|
}
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
|
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
|
// ohnehin mehr.
|
|
.navigationTitle("")
|
|
.flixyInlineTitle()
|
|
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
|
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
|
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
|
// Aufmacher.
|
|
.flixyClearToolbarBackground()
|
|
// Gar keine Navigationsleiste auf der Startseite.
|
|
//
|
|
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
|
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
|
|
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
|
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.task(id: session.serverName) { await load() }
|
|
.refreshable { await load() }
|
|
}
|
|
|
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
|
let featured = heroItems(in: hubs)
|
|
if !featured.isEmpty {
|
|
HeroCarousel(items: featured)
|
|
}
|
|
|
|
ForEach(hubs) { hub in
|
|
MediaRow(row: hub)
|
|
}
|
|
}
|
|
.padding(.bottom, FlixySpacing.section)
|
|
}
|
|
.scrollIndicators(.never)
|
|
.flixyRevealsTopChrome()
|
|
}
|
|
|
|
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
|
|
///
|
|
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
|
|
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
|
|
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
|
|
/// Plex und Jellyfin gleichermaßen.
|
|
private func prepared(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
|
var rows = rows
|
|
|
|
if layout.hidesWatchedInLatest {
|
|
rows = rows.map { row in
|
|
guard row.id.hasPrefix("latest") else { return row }
|
|
return MediaRowGroup(
|
|
id: row.id,
|
|
title: row.title,
|
|
moreKey: row.moreKey,
|
|
items: row.items.filter { !$0.isWatched }
|
|
)
|
|
}
|
|
}
|
|
|
|
if layout.combinesContinueRows {
|
|
rows = combiningContinueRows(rows)
|
|
}
|
|
|
|
return rows.filter { !$0.items.isEmpty }
|
|
}
|
|
|
|
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
|
|
///
|
|
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
|
|
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
|
|
/// wieder ganz unten.
|
|
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
|
let continuing = rows.filter(\.isContinueWatching)
|
|
guard continuing.count > 1, let first = continuing.first else { return rows }
|
|
|
|
var seen = Set<String>()
|
|
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
|
|
|
|
var result: [MediaRowGroup] = []
|
|
for row in rows {
|
|
guard row.isContinueWatching else {
|
|
result.append(row)
|
|
continue
|
|
}
|
|
guard row.id == first.id else { continue }
|
|
result.append(MediaRowGroup(id: row.id, title: row.title, items: merged))
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Was oben groß gezeigt wird.
|
|
///
|
|
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
|
|
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
|
|
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
|
|
/// Schaufenster versteht, stellt auf Neuzugänge um.
|
|
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
|
|
let candidates: [MediaItem]
|
|
switch layout.heroSource {
|
|
case .continueFirst:
|
|
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
|
|
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
|
|
candidates = continuing + rest
|
|
case .latestOnly:
|
|
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
let unique = candidates.filter { seen.insert($0.id).inserted }
|
|
|
|
// **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 {
|
|
guard let backend = session.backend else { return }
|
|
state = await LoadState.load(keepingPrevious: state) {
|
|
try await backend.homeRows()
|
|
}
|
|
}
|
|
}
|