Files
Flaro/Flixy/Session/HomeLayoutSettings.swift
T
ScarriffleandClaude Opus 5 df75fa98a5 Kachelgröße und Aufmacherhöhe gibt es jetzt auch in der App
Phase 1 des Einstellungs-Abgleichs. Beide standen bisher nur im Web,
obwohl sie nirgends hingehören, wo es die Sache nicht überall gibt.

Die Kachelgröße ist ein Faktor und keine Punktzahl: die Breiten von
Plakat und Querformat stehen in einem Verhältnis, das stimmen soll —
zwei getrennte Zahlen ließen sich gegeneinander verstellen, bis eine
Reihe zur anderen nicht mehr passt. Sie wirkt auf Startseite und
Mediathek, sonst sähe das eine anders aus als das andere.

Sie steht bei der Darstellung, nicht bei der Startseite: es geht ums
Aussehen, und sie gilt für beide Seiten. Die Aufmacherhöhe steht bei der
Startseite, direkt bei Quelle und Anzahl.

Voreingestellt ist genau das bisherige feste Verhalten — wer nichts
anfasst, merkt nichts. Gelesene Werte werden auf ihren Bereich begrenzt,
damit eine ältere Ablage keine Kachel über den halben Bildschirm ergibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:02:50 +02:00

150 lines
6.3 KiB
Swift

import Foundation
import Observation
import MediaKit
import PlexKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
/// Server, verschwundene aus der gespeicherten Liste.
@MainActor
@Observable
final class HomeLayoutSettings {
private enum Key {
static let hidden = "flixy.home.hiddenHubs"
static let order = "flixy.home.hubOrder"
static let heroCount = "flixy.home.heroCount"
static let heroSource = "flixy.home.heroSource"
static let combinesContinueRows = "flixy.home.combinesContinueRows"
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
static let cardScale = "flixy.home.cardScale"
static let heroHeight = "flixy.home.heroHeight"
}
/// Woraus sich der Aufmacher speist.
enum HeroSource: String, CaseIterable, Identifiable, Sendable {
/// Angefangenes zuerst, danach aufgefüllt.
case continueFirst
/// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster
/// versteht und nicht als Fortsetzung.
case latestOnly
var id: String { rawValue }
}
/// Wie viele Titel der Aufmacher durchwechselt.
static let heroCountChoices = [1, 3, 5, 7, 10]
/// Die Grenzen der Kachelgröße.
///
/// Nach unten, wo ein Plakat noch erkennbar ist; nach oben, wo auf einem
/// iPad noch mehr als zwei nebeneinander passen — sonst ist es keine Reihe
/// mehr, sondern eine Abfolge.
static let cardScaleRange: ClosedRange<Double> = 0.7...1.8
/// Wie viel vom Bildschirm der Aufmacher einnehmen darf.
///
/// Nach unten bleibt er ein breites Vorschaubild, nach oben verdeckt er
/// die erste Reihe ganz — und dann weiß niemand, dass es weitergeht.
static let heroHeightRange: ClosedRange<Double> = 0.4...0.95
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } }
var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } }
/// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind.
///
/// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und
/// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel
/// schaut.
var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } }
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } }
/// Wie groß Plakate und Vorschaubilder erscheinen, als Faktor auf die
/// gestalterisch festgelegten Breiten.
///
/// Ein Faktor und keine Punktzahl: die Breiten von Plakat und Querformat
/// stehen in einem Verhältnis zueinander, das stimmen soll. Zwei getrennte
/// Zahlen ließen sich gegeneinander verstellen, bis eine Reihe zur anderen
/// nicht mehr passt.
///
/// **Pro Gerät**, nicht über den Server: am Fernseher will man größere
/// Kacheln als am Laptop, und das eine soll das andere nicht umstellen.
var cardScale: Double { didSet { defaults.set(cardScale, forKey: Key.cardScale) } }
/// Welchen Anteil der sichtbaren Höhe der Aufmacher einnimmt.
var heroHeight: Double { didSet { defaults.set(heroHeight, forKey: Key.heroHeight) } }
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5
self.heroSource = defaults.string(forKey: Key.heroSource)
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false
// Die Voreinstellungen sind genau das bisherige feste Verhalten: wer
// nichts anfasst, merkt von der neuen Einstellung nichts.
self.cardScale = (defaults.object(forKey: Key.cardScale) as? Double)
.map { $0.clamped(to: Self.cardScaleRange) } ?? 1.0
self.heroHeight = (defaults.object(forKey: Key.heroHeight) as? Double)
.map { $0.clamped(to: Self.heroHeightRange) } ?? 0.82
}
func isHidden(_ identifier: String) -> Bool {
arrangement.hidden.contains(identifier)
}
func setHidden(_ hidden: Bool, for identifier: String) {
if hidden {
arrangement.hidden.insert(identifier)
} else {
arrangement.hidden.remove(identifier)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
func setOrder(_ identifiers: [String]) {
arrangement.order = identifiers
defaults.set(identifiers, forKey: Key.order)
}
func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] {
arrangement.apply(to: rows)
}
/// Die Zeilen für den Einstellungsbildschirm.
func rows(from rows: [MediaRowGroup]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in
guard let row = rows.first(where: { $0.id == identifier }) else { return nil }
return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier))
}
}
}
/// Eine Reihe im Einstellungsbildschirm.
struct HomeRow: Identifiable, Hashable {
let id: String
let title: String
var isHidden: Bool
}
extension Comparable {
/// Auf einen Bereich begrenzt.
///
/// Gespeicherte Werte werden beim Lesen geprüft, nicht blind übernommen:
/// eine ältere Fassung mit anderen Grenzen — oder ein von Hand
/// verändertes Voreinstellungsblatt — soll nicht zu einer Kachel führen,
/// die den halben Bildschirm einnimmt.
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}