Platzhalter statt Ratespiel, und zwei Griffe im Player

**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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-27 18:50:26 +02:00
co-authored by Claude Opus 5
parent 279d8e36ae
commit 10caa5fdd1
4 changed files with 190 additions and 5 deletions
+17
View File
@@ -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" : {
+41 -2
View File
@@ -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..<pendingCount, id: \.self) { position in
PlaceholderCard(index: position)
}
}
.padding(FlixySpacing.screenMargin)
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
moreButton.padding(.bottom, FlixySpacing.section)
footerCount.padding(.bottom, FlixySpacing.section)
}
.flixyRevealsTopChrome()
}
@@ -126,12 +133,44 @@ struct LibraryView: View {
}
}
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
if pendingCount > 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 {
+71 -3
View File
@@ -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 {