diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 95a8b92..4727c3e 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -67,6 +67,12 @@ struct FlixyApp: App { var body: some Scene { WindowGroup { RootView() + // Eine Untergrenze, unter der die Oberfläche nicht mehr + // aufgeht: schmaler als das hier klappt die Seitenleiste zu + // und die Reihen zeigen anderthalb Kacheln. + #if os(macOS) + .frame(minWidth: 900, minHeight: 620) + #endif .environment(session) .environment(playback) .environment(playbackSettings) @@ -102,6 +108,17 @@ struct FlixyApp: App { } } .modelContainer(modelContainer) + #if os(macOS) + // Ohne diese Angabe öffnet macOS ein Fenster von der Größe eines + // Notizzettels — und darin fällt der Anmeldebildschirm in sich + // zusammen, weil seine Abstände dehnbar sind. + .defaultSize(width: 1280, height: 820) + // Das Fenster darf nicht kleiner werden, als der Inhalt verträgt. + .windowResizability(.contentMinSize) + // Keine graue Titelleiste über einem dunklen Bild: die Ampelknöpfe + // schweben über dem Inhalt, wie man es von Fernseh-Apps kennt. + .windowStyle(.hiddenTitleBar) + #endif } } diff --git a/Flixy/Views/Detail/ParentLinks.swift b/Flixy/Views/Detail/ParentLinks.swift index 05b78d7..9e1850f 100644 --- a/Flixy/Views/Detail/ParentLinks.swift +++ b/Flixy/Views/Detail/ParentLinks.swift @@ -35,7 +35,7 @@ struct ParentLinks: View { } .padding(.horizontal, FlixySpacing.screenMargin) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } } diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 3ab27c9..6217d86 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -52,7 +52,7 @@ struct PlayableDetailView: View { } .padding(.bottom, FlixySpacing.section) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } private var currentMedia: MediaVersion? { @@ -162,7 +162,7 @@ struct TagStrip: View { } .padding(.horizontal, FlixySpacing.screenMargin) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } } @@ -209,7 +209,7 @@ struct CastRow: View { } .padding(.horizontal, FlixySpacing.screenMargin) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } } } diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 98ea3a1..65a4ed1 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -34,7 +34,7 @@ struct SeriesDetailView: View { } .padding(.bottom, FlixySpacing.section) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) .task(id: item.id) { await loadChildren() } } diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index 87fb664..30eab80 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -52,7 +52,10 @@ struct HeroCarousel: View { } .scrollTargetBehavior(.paging) .scrollPosition(id: $shownID) - .scrollIndicators(.hidden) + // `.never`, nicht `.hidden`: wer in den Systemeinstellungen + // „Scrollbalken immer einblenden" gewählt hat, bekam sonst einen + // grauen Streifen quer unter den Aufmacher gelegt. + .scrollIndicators(.never) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) // Jede Berührung hält das Weiterwandern an — auch die, die nur ein // Antippen wird. Gleichzeitig und ohne Mindestweg, damit weder dem diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 25eb0d2..9344fed 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -65,7 +65,7 @@ struct HomeView: View { } .padding(.bottom, FlixySpacing.section) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } /// Bringt die Reihen vom Server in die Form, die eingestellt ist. diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index 113907c..fb69743 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -37,7 +37,7 @@ struct MediaRow: View { .scrollTargetLayout() } .scrollTargetBehavior(.viewAligned) - .scrollIndicators(.hidden) + .scrollIndicators(.never) } } diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index 8466de3..70103aa 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -70,6 +70,7 @@ struct LibraryFilterSheet: View { // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // leuchteten, während die Schalter daneben schon die gewählte trugen. .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("filter.title")) diff --git a/Flixy/Views/LiveTV/ProgramGuide.swift b/Flixy/Views/LiveTV/ProgramGuide.swift index 6c6b0b9..214005a 100644 --- a/Flixy/Views/LiveTV/ProgramGuide.swift +++ b/Flixy/Views/LiveTV/ProgramGuide.swift @@ -56,7 +56,7 @@ struct ProgramGuide: View { lanes } } - .scrollIndicators(.hidden) + .scrollIndicators(.never) } .background(Color.flixyCanvas) .sheet(item: $selection) { selection in @@ -193,7 +193,7 @@ struct ProgramGuide: View { } .frame(width: layout.totalWidth, alignment: .topLeading) } - .scrollIndicators(.hidden) + .scrollIndicators(.never) .frame(height: CGFloat(channels.count) * rowHeight) .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.x diff --git a/Flixy/Views/Navigation/AccountBar.swift b/Flixy/Views/Navigation/AccountBar.swift index be3d1ef..64e1437 100644 --- a/Flixy/Views/Navigation/AccountBar.swift +++ b/Flixy/Views/Navigation/AccountBar.swift @@ -94,8 +94,11 @@ struct AccountBar: View { .lineLimit(1) } } + // Ohne Vorrang staucht SwiftUI den Text vor dem Zwischenraum: + // in einer schmalen Leiste stand vom Namen nur noch „S…" da. + .layoutPriority(1) - Spacer(minLength: FlixySpacing.snug) + Spacer(minLength: 0) // Zeigt nach oben: dorthin geht die Karte auf. Image(systemName: "chevron.up") diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 41d46a9..4907e0e 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -187,11 +187,11 @@ struct RootView: View { TabSection { ForEach(session.libraries) { library in Tab( - library.title, - systemImage: symbol(for: library.kind), value: SidebarDestination.library(key: library.id, title: library.title) ) { mediaStack { LibraryView(library: library) } + } label: { + tabLabel(library.title, symbol: symbol(for: library.kind)) } .customizationID("flixy.tab.library.\(library.id)") } @@ -201,37 +201,33 @@ struct RootView: View { .customizationID("flixy.tab.libraries") if session.supportsLiveTV { - Tab( - "sidebar.liveTV", - systemImage: "dot.radiowaves.left.and.right", - value: SidebarDestination.liveTV - ) { + Tab(value: SidebarDestination.liveTV) { mediaStack { LiveTVView() } + } label: { + tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right") } .customizationID("flixy.tab.liveTV") } } else { - Tab( - "sidebar.library", - systemImage: "books.vertical", - value: SidebarDestination.libraryHub - ) { + Tab(value: SidebarDestination.libraryHub) { mediaStack { LibraryHubView() } + } label: { + tabLabel("sidebar.library", symbol: "books.vertical") } .customizationID("flixy.tab.libraryHub") } - Tab( - "sidebar.downloads", - systemImage: "arrow.down.circle", - value: SidebarDestination.downloads - ) { + Tab(value: SidebarDestination.downloads) { mediaStack { DownloadsView() } + } label: { + tabLabel("sidebar.downloads", symbol: "arrow.down.circle") } .customizationID("flixy.tab.downloads") - Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) { + Tab(value: SidebarDestination.settings) { mediaStack { SettingsView() } + } label: { + tabLabel("sidebar.settings", symbol: "gearshape") } .customizationID("flixy.tab.settings") @@ -274,6 +270,32 @@ struct RootView: View { } } + /// Die Beschriftung eines Eintrags. + /// + /// Auf dem Mac mit einer Mindestbreite. Die Seitenleiste richtet sich nach + /// ihrem Inhalt, und bei kurzen Wörtern wie „Start" fällt sie auf eine + /// Breite zusammen, in der schon der Kontoname darunter nicht mehr Platz + /// hat — auf dem iPad ist sie dreimal so breit, und genau dieser + /// Unterschied macht die beiden Oberflächen ungleich. + private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View { + let label = Label(title, systemImage: symbol) + #if os(macOS) + return label.frame(minWidth: 170, alignment: .leading) + #else + return label + #endif + } + + /// Dieselbe Beschriftung, wenn der Titel erst vom Server kommt. + private func tabLabel(_ title: String, symbol: String) -> some View { + let label = Label(title, systemImage: symbol) + #if os(macOS) + return label.frame(minWidth: 170, alignment: .leading) + #else + return label + #endif + } + private func symbol(for kind: MediaLibrary.Kind) -> String { switch kind { case .movie: "film" diff --git a/Flixy/Views/Settings/AppearanceView.swift b/Flixy/Views/Settings/AppearanceView.swift index d447d07..fe08aae 100644 --- a/Flixy/Views/Settings/AppearanceView.swift +++ b/Flixy/Views/Settings/AppearanceView.swift @@ -67,6 +67,7 @@ struct AppearanceView: View { PreviewRow() } } + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("appearance.title")) diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift index a8e8482..b557977 100644 --- a/Flixy/Views/Settings/DiagnosticsView.swift +++ b/Flixy/Views/Settings/DiagnosticsView.swift @@ -115,6 +115,7 @@ struct DiagnosticsView: View { // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // leuchteten, während die Schalter daneben schon die gewählte trugen. .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("diagnostics.title")) diff --git a/Flixy/Views/Settings/DownloadSettingsView.swift b/Flixy/Views/Settings/DownloadSettingsView.swift index 44d4e28..0aceb49 100644 --- a/Flixy/Views/Settings/DownloadSettingsView.swift +++ b/Flixy/Views/Settings/DownloadSettingsView.swift @@ -73,6 +73,7 @@ struct DownloadSettingsView: View { } } .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.downloads")) diff --git a/Flixy/Views/Settings/LanguagesView.swift b/Flixy/Views/Settings/LanguagesView.swift index 21b314f..b3fc37f 100644 --- a/Flixy/Views/Settings/LanguagesView.swift +++ b/Flixy/Views/Settings/LanguagesView.swift @@ -59,6 +59,7 @@ struct LanguagesView: View { } } .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("languages.title")) diff --git a/Flixy/Views/Settings/PlaybackSettingsView.swift b/Flixy/Views/Settings/PlaybackSettingsView.swift index 50487a4..aa61776 100644 --- a/Flixy/Views/Settings/PlaybackSettingsView.swift +++ b/Flixy/Views/Settings/PlaybackSettingsView.swift @@ -86,6 +86,7 @@ struct PlaybackSettingsView: View { } } .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("settings.playback")) diff --git a/Flixy/Views/Settings/ServerSettingsView.swift b/Flixy/Views/Settings/ServerSettingsView.swift index 99b3a4e..ed02a0f 100644 --- a/Flixy/Views/Settings/ServerSettingsView.swift +++ b/Flixy/Views/Settings/ServerSettingsView.swift @@ -46,6 +46,7 @@ struct ServerSettingsView: View { } } .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("settings.server")) diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 353c5e5..66661d3 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -80,6 +80,7 @@ struct SettingsView: View { // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // leuchteten, während die Schalter daneben schon die gewählte trugen. .tint(accent) + .flixyForm() .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.settings")) diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 9fbae56..34895e2 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -39,13 +39,25 @@ public struct HeroBanner: View { self.actions = actions() } + /// Wie stark Schrift und Schriftzug mitwachsen. + /// + /// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße + /// wirkt derselbe Titel auf einem iPad groß und in einem breiten + /// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße + /// ist die Breite eines iPads im Hochformat; alles darüber wächst mit, + /// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in + /// Plakatschrift endet. + private func scale(for width: CGFloat) -> CGFloat { + min(max(width / 834, 1), 1.7) + } + /// Schriftzug, wenn vorhanden — sonst gesetzter Titel. /// /// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein /// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem /// freigestellten Logo würde sonst ein schwarzer Kasten. @ViewBuilder - private var titleView: some View { + private func titleView(scale: CGFloat) -> some View { if let logoURL { AsyncImage(url: logoURL) { phase in switch phase { @@ -56,23 +68,23 @@ public struct HeroBanner: View { // Nach oben begrenzt: manche Schriftzüge sind fast // quadratisch, andere sehr breit. Ohne Deckel schöbe // ein hohes Logo die Knöpfe aus dem Bild. - .frame(maxWidth: 260, maxHeight: 76, alignment: .leading) + .frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading) default: // Solange geladen wird — und wenn es scheitert — bleibt es // beim gesetzten Titel. Ein leerer Platz wäre schlimmer // als eine Systemschrift. - plainTitle + plainTitle(scale: scale) } } .accessibilityLabel(Text(title)) } else { - plainTitle + plainTitle(scale: scale) } } - private var plainTitle: some View { + private func plainTitle(scale: CGFloat) -> some View { Text(title) - .font(.flixyHero) + .font(.system(size: 34 * scale, weight: .bold)) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) } @@ -106,29 +118,43 @@ public struct HeroBanner: View { .allowsHitTesting(false) } .overlay(alignment: .bottomLeading) { - VStack(alignment: .leading, spacing: FlixySpacing.snug) { - titleView + GeometryReader { geometry in + let scale = scale(for: geometry.size.width) - if let metadata { - Text(metadata) - .font(.flixyCaption) - .foregroundStyle(Color.flixyTextSecondary) - } + VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) { + titleView(scale: scale) - if let summary { - Text(summary) - .font(.flixyCaption) - .foregroundStyle(Color.flixyTextSecondary) - .lineLimit(2) - } + if let metadata { + Text(metadata) + .font(.system(size: 12 * scale)) + .foregroundStyle(Color.flixyTextSecondary) + } - HStack(spacing: FlixySpacing.regular) { - actions + if let summary { + Text(summary) + .font(.system(size: 12 * scale)) + .foregroundStyle(Color.flixyTextSecondary) + .lineLimit(2) + // Ein Satz über die ganze Breite eines großen + // Fensters ist unlesbar; das Auge verliert die + // Zeile. Achtzig Zeichen sind die Grenze, ab + // der Satzspiegel unangenehm werden. + .frame(maxWidth: 640 * scale, alignment: .leading) + } + + HStack(spacing: FlixySpacing.regular) { + actions + } + .padding(.top, FlixySpacing.tight) } - .padding(.top, FlixySpacing.tight) + .padding(.horizontal, FlixySpacing.screenMargin * scale) + .padding(.bottom, FlixySpacing.loose * scale) + .frame( + width: geometry.size.width, + height: geometry.size.height, + alignment: .bottomLeading + ) } - .padding(.horizontal, FlixySpacing.screenMargin) - .padding(.bottom, FlixySpacing.loose) } } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift index e29559d..a25ad5b 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -2,10 +2,33 @@ import SwiftUI /// Die Schriftskala der App. /// -/// Durchgehend die Systemschrift mit `Font.TextStyle` als Grundlage, damit -/// Dynamic Type wirkt. Die Rolle steht im Namen, nicht die Größe — sonst -/// führt jede Anpassung zu einer Suche quer durch alle Views. +/// Auf iOS die Systemstile, damit Dynamic Type wirkt. Die Rolle steht im +/// Namen, nicht die Größe — sonst führt jede Anpassung zu einer Suche quer +/// durch alle Views. +/// +/// **Auf dem Mac stehen feste Größen.** Dieselben Stile sind dort deutlich +/// kleiner: `largeTitle` misst 26 Punkt statt 34, `body` 13 statt 17. Eine +/// Oberfläche, die auf dem iPad stimmt, wirkt damit auf dem Mac zusammen- +/// geschrumpft — und genau das war sie. Die Zahlen hier sind die von iOS. +/// +/// Das kostet auf dem Mac Dynamic Type. Das ist vertretbar: macOS hat keine +/// systemweite Textgrößen-Einstellung, auf die Apps reagieren müssten, und ein +/// Fenster lässt sich stattdessen größer ziehen. extension Font { + #if os(macOS) + /// Der Titel im Aufmacher-Bild. + public static let flixyHero = Font.system(size: 34, weight: .bold) + /// Titel auf Detailseiten. + public static let flixyTitle = Font.system(size: 22, weight: .semibold) + /// Überschrift einer Reihe auf der Startseite. + public static let flixyRowHeader = Font.system(size: 17, weight: .semibold) + /// Titel unter einem Poster. + public static let flixyCardTitle = Font.system(size: 15, weight: .medium) + /// Fließtext, Beschreibungen. + public static let flixyBody = Font.system(size: 17) + /// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht. + public static let flixyCaption = Font.system(size: 12) + #else /// Der Titel im Aufmacher-Bild. public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold) /// Titel auf Detailseiten. @@ -18,6 +41,7 @@ extension Font { public static let flixyBody = Font.system(.body) /// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht. public static let flixyCaption = Font.system(.caption) + #endif } /// Abstände. diff --git a/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift index e07bc84..33cf720 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift @@ -43,6 +43,21 @@ extension View { #endif } + /// Ein Formular, das auf beiden Seiten gleich aussieht. + /// + /// Auf dem Mac setzt SwiftUI sonst den Spalten-Stil: Beschriftungen rechts + /// ausgerichtet, keine abgesetzten Abschnitte, alles eng. Der gruppierte + /// Stil ist derselbe, den iOS ohnehin nimmt — und der Grund, warum die + /// Einstellungen auf dem Mac wie ein Systemdialog aus einem anderen + /// Jahrzehnt aussahen. + public func flixyForm() -> some View { + #if os(macOS) + formStyle(.grouped) + #else + self + #endif + } + /// Eingabefelder für Adressen und Namen: keine Großschreibung, keine /// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur. public func flixyPlainEntry(isAddress: Bool = false) -> some View {