From 10caa5fdd19284c6136cb73fe83bab83572738f0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 27 Sep 2026 18:50:26 +0200 Subject: [PATCH] Platzhalter statt Ratespiel, und zwei Griffe im Player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Das Raster sagt jetzt, ob noch etwas kommt.** Eine Mediathek lädt seitenweise nach, und wer unten ankam, sah ein Raster, das aufhört — ohne unterscheiden zu können, ob das alles war oder gerade nachgeladen wird. Beides sah gleich aus. Beide Server nennen die Gesamtzahl (Jellyfin `TotalRecordCount`, Plex `totalSize`), und wir lesen sie längst. Also stehen die nächsten Kacheln schon da, in der Form, die sie haben werden — gedeckelt auf ein Dutzend, weil zweihundert Platzhalter niemandem helfen. Ist wirklich alles da, steht es am Ende ausdrücklich: „Alle 252 Titel geladen". Gegen die Attrappe mit 252 Titeln durchgeblättert und nachgesehen: die Zeile steht da, und alle 252 sind angekommen. **Herein sanft, hinaus zügig.** Das Einblenden der Bedienung begleitet eine Bewegung des Zeigers und soll wirken, als käme sie der Hand entgegen; zwei Zehntel wirkten wie ein Aufblitzen. Das Ausblenden dagegen ist ein Aufräumen, und dabei muss niemand zusehen. **Ein Klick ins Schwarze blendet um, statt anzuhalten.** Ein Film füllt das Fenster fast nie ganz aus — die Balken daneben gehören nicht zum Film, und dort zu klicken ist die natürliche Geste für „zeig mir die Bedienung". Wo das Bild aufhört, wird aus `presentationSize` gerechnet, also aus dem, was wirklich ankommt. Fehlen die Maße, verhält es sich wie bisher. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 17 +++++ Flixy/Views/Library/LibraryView.swift | 43 ++++++++++- Flixy/Views/Player/PlayerView.swift | 74 ++++++++++++++++++- .../Components/PlaceholderCard.swift | 61 +++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/PlaceholderCard.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 2d513b7..d3c78cb 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2313,6 +2313,23 @@ } } }, + "library.allLoaded %lld" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle %lld Titel geladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All %lld titles loaded" + } + } + } + }, "library.clearFilters" : { "extractionState" : "stale", "localizations" : { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index a92e241..4579104 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -103,11 +103,18 @@ struct LibraryView: View { .buttonStyle(.plain) .task { await loadMoreIfNeeded(reaching: item) } } + + // Was noch kommt, steht schon da — in der Form, die es haben + // wird. Das beantwortet die Frage, die ein aufhörendes Raster + // offen lässt: war das alles? + ForEach(0.. 0 { + ProgressView() + .padding(.vertical, FlixySpacing.section) + .frame(maxWidth: .infinity) + } moreButton.padding(.vertical, FlixySpacing.section) + footerCount.padding(.bottom, FlixySpacing.section) } .flixyRevealsTopChrome() } + /// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten. + /// + /// Beide Server nennen die Gesamtzahl: Jellyfin als `TotalRecordCount`, + /// Plex als `totalSize`. Damit ist die Frage „war das alles?" beantwortbar, + /// statt dass man am Ende des Rasters rät. + /// + /// Gedeckelt, weil zweihundert Platzhalter niemandem helfen: ein Dutzend + /// sagt „es geht weiter" genauso gut und hält die Liste flüssig. + private var pendingCount: Int { + guard !loadMoreFailed, let page, page.hasMore else { return 0 } + return min(page.totalCount - items.count, 12) + } + + /// Die Zeile am Ende, wenn wirklich alles da ist. + /// + /// Ohne sie bleibt der Zweifel: ein Raster, das aufhört, sieht genauso + /// aus wie eines, das noch lädt. + @ViewBuilder + private var footerCount: some View { + if let page, !page.hasMore, !items.isEmpty { + Text("library.allLoaded \(items.count)") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .frame(maxWidth: .infinity) + } + } + /// Der Weg weiter, wenn der Nachschub hängen geblieben ist. @ViewBuilder private var moreButton: some View { diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 1161dc5..dcc399d 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -42,6 +42,9 @@ struct PlayerView: View { @FocusState private var keyboardFocus: Bool /// Wann die App zuletzt nach vorn geholt wurde. @State private var becameActive: ContinuousClock.Instant? + /// Wie gross die Player-Fläche gerade ist — für die Frage, wo das Bild + /// aufhört und das Schwarze anfängt. + @State private var playerSize: CGSize = .zero /// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt. private var isMac: Bool { FlixyPlatform.isMac } /// Wann der Zeiger zuletzt die Bedienelemente geholt hat. @@ -156,8 +159,10 @@ struct PlayerView: View { .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) - // Für alles außerhalb der Sprungflächen. - .onTapGesture { primaryTap() } + // Für alles außerhalb der Sprungflächen. Mit Ort, weil es einen + // Unterschied macht, **wo** geklickt wurde — siehe `tap(at:)`. + .onGeometryChange(for: CGSize.self) { $0.size } action: { playerSize = $0 } + .onTapGesture(coordinateSpace: .local) { tap(at: $0) } .onReceive( NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) ) { _ in @@ -654,6 +659,64 @@ struct PlayerView: View { /// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung: /// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber, /// Tonspur und Qualität. + /// Ein Klick — und die Frage, worauf. + /// + /// **Auf das Bild** heißt anhalten: das ist der Griff, den man aus jedem + /// Player kennt. + /// + /// **Ins Schwarze daneben** heißt ein- und ausblenden. Ein Film füllt das + /// Fenster fast nie ganz aus — oben und unten oder links und rechts + /// bleiben Balken, und die gehören nicht zum Film. Dort zu klicken ist + /// die natürliche Geste für „zeig mir die Bedienung" und war bisher + /// dasselbe wie ein Griff mitten ins Bild. + private func tap(at point: CGPoint) { + guard isMac, !videoRect.contains(point) else { + primaryTap() + return + } + toggleControls() + } + + /// Wo das Bild tatsächlich liegt. + /// + /// Der Player füllt das ganze Fenster, das Bild aber nur den Teil, der + /// zum Seitenverhältnis passt — der Rest ist schwarz. Die Maße kommen + /// aus `presentationSize`, also von dem, was wirklich ankommt, nicht aus + /// den Angaben der Datei. + /// + /// Ohne Maße die ganze Fläche: dann verhält es sich wie bisher, statt + /// aus Unkenntnis überall das Falsche zu tun. + private var videoRect: CGRect { + let ganzes = CGRect(origin: .zero, size: playerSize) + guard let breite = model.statistics?.width, + let höhe = model.statistics?.height, + breite > 0, höhe > 0, + playerSize.width > 0, playerSize.height > 0 + else { return ganzes } + + let verhältnisBild = CGFloat(breite) / CGFloat(höhe) + let verhältnisFläche = playerSize.width / playerSize.height + + if verhältnisBild > verhältnisFläche { + // Breiter als das Fenster: Balken oben und unten. + let sichtbar = playerSize.width / verhältnisBild + return CGRect( + x: 0, + y: (playerSize.height - sichtbar) / 2, + width: playerSize.width, + height: sichtbar + ) + } + // Höher als das Fenster: Balken links und rechts. + let sichtbar = playerSize.height * verhältnisBild + return CGRect( + x: (playerSize.width - sichtbar) / 2, + y: 0, + width: sichtbar, + height: playerSize.height + ) + } + private func primaryTap() { guard isMac else { toggleControls() @@ -698,7 +761,12 @@ struct PlayerView: View { /// Zeigt die Bedienelemente und setzt die Ausblendfrist neu. private func revealControls() { if !showsControls { - withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } + // **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine + // Bewegung des Zeigers und soll sich anfühlen, als käme die + // Bedienung der Hand entgegen — zwei Zehntel wirken dabei wie ein + // Aufblitzen. Das Ausblenden dagegen ist ein Aufräumen, und + // Aufräumen darf man nicht zusehen müssen. + withAnimation(.easeOut(duration: 0.45)) { showsControls = true } // Beim Aufdecken nachsehen, was inzwischen läuft — genau dann // will man es wissen. if model.isLive { diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/PlaceholderCard.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/PlaceholderCard.swift new file mode 100644 index 0000000..9d98a48 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/PlaceholderCard.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Eine Kachel, die es gleich geben wird. +/// +/// **Warum es das gibt.** Eine Mediathek lädt seitenweise nach. Wer unten +/// ankommt, sieht ein Raster, das aufhört — und kann nicht unterscheiden, ob +/// das alles war oder ob gerade nachgeladen wird. Beides sieht gleich aus, und +/// im Zweifel hält man eine halb geladene Liste für vollständig. +/// +/// Diese Kacheln sagen: hier kommt noch etwas. Sie werden nur so oft gezeigt, +/// wie der Server tatsächlich noch Titel gemeldet hat — es ist also keine +/// Beschwichtigung, sondern eine Auskunft. +/// +/// Ein Ladekreis täte das nicht: er sagt „es passiert etwas", aber nicht +/// **wo** und nicht **wie viel**. Die Form der Kachel an der Stelle, an der +/// sie erscheinen wird, sagt beides. +public struct PlaceholderCard: View { + /// Womit das Aufleuchten versetzt wird. + /// + /// Alle gleichzeitig pulsieren zu lassen sieht nach einem Fehler aus — + /// als blinkte die ganze Seite. Ein Versatz je Kachel macht daraus eine + /// Bewegung, die von links nach rechts läuft. + let index: Int + + @State private var isBright = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(index: Int = 0) { + self.index = index + } + + public var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.snug) { + RoundedRectangle(cornerRadius: FlixyRadius.card) + .fill(Color.flixyElevated) + .aspectRatio(PosterCard.aspectRatio, contentMode: .fit) + + // Zwei Balken an der Stelle von Titel und Jahr. Ohne sie springt + // die Zeile, sobald der echte Titel kommt. + RoundedRectangle(cornerRadius: 3) + .fill(Color.flixyElevated) + .frame(height: 10) + RoundedRectangle(cornerRadius: 3) + .fill(Color.flixyElevated) + .frame(width: 32, height: 8) + } + .opacity(isBright ? 0.85 : 0.45) + .animation( + reduceMotion + ? nil + : .easeInOut(duration: 0.9) + .repeatForever(autoreverses: true) + .delay(Double(index % 6) * 0.1), + value: isBright + ) + .onAppear { isBright = true } + // Für die Sprachausgabe ist eine Kachel ohne Inhalt nichts, worüber + // man wischen möchte. + .accessibilityHidden(true) + } +}