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:
co-authored by
Claude Opus 5
parent
279d8e36ae
commit
10caa5fdd1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user