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>
This commit is contained in:
Scarriffle
2026-09-14 17:02:50 +02:00
co-authored by Claude Opus 5
parent 653951c1d8
commit df75fa98a5
7 changed files with 154 additions and 4 deletions
+51
View File
@@ -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" : { "appearance.customColor" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "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" : { "home.heroSource" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
+48
View File
@@ -19,6 +19,8 @@ final class HomeLayoutSettings {
static let heroSource = "flixy.home.heroSource" static let heroSource = "flixy.home.heroSource"
static let combinesContinueRows = "flixy.home.combinesContinueRows" static let combinesContinueRows = "flixy.home.combinesContinueRows"
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest" static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
static let cardScale = "flixy.home.cardScale"
static let heroHeight = "flixy.home.heroHeight"
} }
/// Woraus sich der Aufmacher speist. /// Woraus sich der Aufmacher speist.
@@ -35,6 +37,19 @@ final class HomeLayoutSettings {
/// Wie viele Titel der Aufmacher durchwechselt. /// Wie viele Titel der Aufmacher durchwechselt.
static let heroCountChoices = [1, 3, 5, 7, 10] 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 let defaults: UserDefaults
private(set) var arrangement: Arrangement private(set) var arrangement: Arrangement
@@ -49,6 +64,21 @@ final class HomeLayoutSettings {
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet. /// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } } 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) { init(defaults: UserDefaults = .standard) {
self.defaults = defaults self.defaults = defaults
self.arrangement = Arrangement( self.arrangement = Arrangement(
@@ -60,6 +90,12 @@ final class HomeLayoutSettings {
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst .flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) 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 { func isHidden(_ identifier: String) -> Bool {
@@ -99,3 +135,15 @@ struct HomeRow: Identifiable, Hashable {
let title: String let title: String
var isHidden: Bool 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)
}
}
+2 -1
View File
@@ -65,6 +65,7 @@ struct HeroCarousel: View {
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@Environment(TopChrome.self) private var chrome @Environment(TopChrome.self) private var chrome
@Environment(HomeLayoutSettings.self) private var layout
/// Wie weit der Aufmacher nach oben unter die Insel greift. /// 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 // Auf einem schmalen Gerät nicht: dort ist der Aufmacher hochkant
// und rechnet sich seine Höhe aus der Breite aus. // 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 // **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
// eigenes mit, und eines darüber legte ihn auf die Breite des // eigenes mit, und eines darüber legte ihn auf die Breite des
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste // Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
+7 -2
View File
@@ -8,12 +8,17 @@ struct MediaRow: View {
@Environment(FlixySession.self) private var session @Environment(FlixySession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. /// 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 /// Schmaler, weil hochkant: bei gleicher Höhe passen so mehr Plakate
/// nebeneinander, und mehr Auswahl auf einen Blick ist genau der Zweck. /// 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 { var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) { VStack(alignment: .leading, spacing: FlixySpacing.regular) {
+4 -1
View File
@@ -7,6 +7,9 @@ struct LibraryView: View {
let library: MediaLibrary let library: MediaLibrary
@Environment(FlixySession.self) private var session @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 @AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@@ -79,7 +82,7 @@ struct LibraryView: View {
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht // Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten, // ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss. // 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 spacing: FlixySpacing.loose
) { ) {
ForEach(items) { item in ForEach(items) { item in
+25
View File
@@ -9,6 +9,7 @@ import FlixyDesign
/// stillschweigend zu verändern. /// stillschweigend zu verändern.
struct AppearanceView: View { struct AppearanceView: View {
@Environment(FlixyTheme.self) private var theme @Environment(FlixyTheme.self) private var theme
@Environment(HomeLayoutSettings.self) private var layout
@State private var customHex: String = "" @State private var customHex: String = ""
@State private var showsCustomField = false @State private var showsCustomField = false
@@ -29,6 +30,30 @@ struct AppearanceView: View {
Text("appearance.accentExplanation") 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 { Section {
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy)) Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
+17
View File
@@ -39,6 +39,23 @@ struct HomeLayoutView: View {
Text(count.formatted()).tag(count) 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: { } header: {
Text("home.hero") Text("home.hero")
} footer: { } footer: {