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() 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() 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() } } }