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
@@ -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" : {
|
"library.clearFilters" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -103,11 +103,18 @@ struct LibraryView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.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)
|
.padding(FlixySpacing.screenMargin)
|
||||||
|
|
||||||
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
|
|
||||||
moreButton.padding(.bottom, FlixySpacing.section)
|
moreButton.padding(.bottom, FlixySpacing.section)
|
||||||
|
footerCount.padding(.bottom, FlixySpacing.section)
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.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)
|
moreButton.padding(.vertical, FlixySpacing.section)
|
||||||
|
footerCount.padding(.bottom, FlixySpacing.section)
|
||||||
}
|
}
|
||||||
.flixyRevealsTopChrome()
|
.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.
|
/// Der Weg weiter, wenn der Nachschub hängen geblieben ist.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var moreButton: some View {
|
private var moreButton: some View {
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ struct PlayerView: View {
|
|||||||
@FocusState private var keyboardFocus: Bool
|
@FocusState private var keyboardFocus: Bool
|
||||||
/// Wann die App zuletzt nach vorn geholt wurde.
|
/// Wann die App zuletzt nach vorn geholt wurde.
|
||||||
@State private var becameActive: ContinuousClock.Instant?
|
@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.
|
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
private var isMac: Bool { FlixyPlatform.isMac }
|
||||||
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
|
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
|
||||||
@@ -156,8 +159,10 @@ struct PlayerView: View {
|
|||||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||||
.landscapeWhileVisible(settings.locksLandscape)
|
.landscapeWhileVisible(settings.locksLandscape)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
// Für alles außerhalb der Sprungflächen.
|
// Für alles außerhalb der Sprungflächen. Mit Ort, weil es einen
|
||||||
.onTapGesture { primaryTap() }
|
// Unterschied macht, **wo** geklickt wurde — siehe `tap(at:)`.
|
||||||
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { playerSize = $0 }
|
||||||
|
.onTapGesture(coordinateSpace: .local) { tap(at: $0) }
|
||||||
.onReceive(
|
.onReceive(
|
||||||
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||||
) { _ in
|
) { _ in
|
||||||
@@ -654,6 +659,64 @@ struct PlayerView: View {
|
|||||||
/// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung:
|
/// **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,
|
/// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber,
|
||||||
/// Tonspur und Qualität.
|
/// 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() {
|
private func primaryTap() {
|
||||||
guard isMac else {
|
guard isMac else {
|
||||||
toggleControls()
|
toggleControls()
|
||||||
@@ -698,7 +761,12 @@ struct PlayerView: View {
|
|||||||
/// Zeigt die Bedienelemente und setzt die Ausblendfrist neu.
|
/// Zeigt die Bedienelemente und setzt die Ausblendfrist neu.
|
||||||
private func revealControls() {
|
private func revealControls() {
|
||||||
if !showsControls {
|
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
|
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann
|
||||||
// will man es wissen.
|
// will man es wissen.
|
||||||
if model.isLive {
|
if model.isLive {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user