diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 6ef509d..78caca0 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -339,6 +339,40 @@ } } }, + "appearance.cardScale" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kachelgröße" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tile size" + } + } + } + }, + "appearance.cardScaleExplanation" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite und in den Mediatheken. Die Einstellung gilt nur auf diesem Gerät." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How large posters and thumbnails appear — on the home screen and in libraries. This setting applies to this device only." + } + } + } + }, "appearance.customColor" : { "extractionState" : "stale", "localizations" : { @@ -1952,6 +1986,23 @@ } } }, + "home.heroHeight" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Höhe des Aufmachers" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Featured height" + } + } + } + }, "home.heroSource" : { "extractionState" : "stale", "localizations" : { diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift index 642ced5..3e24c92 100644 --- a/Flixy/Session/HomeLayoutSettings.swift +++ b/Flixy/Session/HomeLayoutSettings.swift @@ -19,6 +19,8 @@ final class HomeLayoutSettings { 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. @@ -35,6 +37,19 @@ final class HomeLayoutSettings { /// 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 = 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 = 0.4...0.95 + private let defaults: UserDefaults private(set) var arrangement: Arrangement @@ -49,6 +64,21 @@ final class HomeLayoutSettings { /// 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( @@ -60,6 +90,12 @@ final class HomeLayoutSettings { .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 { @@ -99,3 +135,15 @@ struct HomeRow: Identifiable, Hashable { 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 { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 4f7b869..698d607 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -65,6 +65,7 @@ struct HeroCarousel: View { @Environment(FlixySession.self) private var session @Environment(\.horizontalSizeClass) private var sizeClass @Environment(TopChrome.self) private var chrome + @Environment(HomeLayoutSettings.self) private var layout /// Wie weit der Aufmacher nach oben unter die Insel greift. /// @@ -112,7 +113,7 @@ struct HeroCarousel: View { // // Auf einem schmalen Gerät nicht: dort ist der Aufmacher hochkant // und rechnet sich seine Höhe aus der Breite aus. - .modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.82, extra: overlap)) + .modifier(HeroHeight(fraction: sizeClass == .compact ? nil : layout.heroHeight, extra: overlap)) // **Kein** Seitenverhältnis hier: der Aufmacher bringt sein // eigenes mit, und eines darüber legte ihn auf die Breite des // Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index fb69743..013f80e 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -8,12 +8,17 @@ struct MediaRow: View { @Environment(FlixySession.self) private var session + @Environment(HomeLayoutSettings.self) private var layout + /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. - private let landscapeWidth: CGFloat = 220 + private var landscapeWidth: CGFloat { 220 * layout.cardScale } /// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate /// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck. - private let posterWidth: CGFloat = 116 + /// + /// Beide mit demselben Faktor: ihr Verhältnis zueinander soll stimmen, + /// egal wie groß es insgesamt eingestellt ist. + private var posterWidth: CGFloat { 116 * layout.cardScale } var body: some View { VStack(alignment: .leading, spacing: FlixySpacing.regular) { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index b14a579..e191caa 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -7,6 +7,9 @@ struct LibraryView: View { let library: MediaLibrary @Environment(FlixySession.self) private var session + /// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl + /// zwischen Raster und Liste. + @Environment(HomeLayoutSettings.self) private var sizing @AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid @Environment(\.horizontalSizeClass) private var sizeClass @@ -79,7 +82,7 @@ struct LibraryView: View { // Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht // ergibt auf dem iPhone drei und auf dem iPad sechs Spalten, // ohne dass die Größenklasse abgefragt werden muss. - columns: [GridItem(.adaptive(minimum: 110), spacing: FlixySpacing.regular)], + columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlixySpacing.regular)], spacing: FlixySpacing.loose ) { ForEach(items) { item in diff --git a/Flixy/Views/Settings/AppearanceView.swift b/Flixy/Views/Settings/AppearanceView.swift index fe08aae..d138b49 100644 --- a/Flixy/Views/Settings/AppearanceView.swift +++ b/Flixy/Views/Settings/AppearanceView.swift @@ -9,6 +9,7 @@ import FlixyDesign /// stillschweigend zu verändern. struct AppearanceView: View { @Environment(FlixyTheme.self) private var theme + @Environment(HomeLayoutSettings.self) private var layout @State private var customHex: String = "" @State private var showsCustomField = false @@ -29,6 +30,30 @@ struct AppearanceView: View { Text("appearance.accentExplanation") } + // Die Kachelgröße steht bei der Darstellung und nicht bei der + // Startseite: sie gilt für Reihen **und** Mediathek, und es geht + // ums Aussehen, nicht um den Aufbau einer bestimmten Seite. + Section { + @Bindable var layout = layout + + LabeledContent { + Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0)))) + .foregroundStyle(Color.flixyTextSecondary) + .monospacedDigit() + } label: { + Text("appearance.cardScale") + } + Slider( + value: $layout.cardScale, + in: HomeLayoutSettings.cardScaleRange, + step: 0.1 + ) + .accessibilityLabel(Text("appearance.cardScale")) + } footer: { + Text("appearance.cardScaleExplanation") + } + .listRowBackground(Color.flixySurface) + Section { Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy)) diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index a6a825e..302c82f 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -39,6 +39,23 @@ struct HomeLayoutView: View { Text(count.formatted()).tag(count) } } + + // Ein Regler und keine Auswahl aus festen Stufen: es gibt hier + // keinen richtigen Wert, nur den, bei dem die erste Reihe + // gerade so weit hereinlugt, wie man es mag. + LabeledContent { + Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0)))) + .foregroundStyle(Color.flixyTextSecondary) + .monospacedDigit() + } label: { + Text("home.heroHeight") + } + Slider( + value: $layout.heroHeight, + in: HomeLayoutSettings.heroHeightRange, + step: 0.05 + ) + .accessibilityLabel(Text("home.heroHeight")) } header: { Text("home.hero") } footer: {