Der Klick in den schwarzen Rand trifft jetzt den schwarzen Rand

Er hat weiterhin angehalten statt ein- und auszublenden, und der Grund
steckte in der Rechnung: der schwarze Rand wurde aus der Fenstergrösse
ausgerechnet, das Bild liegt aber in einer Fläche mit `ignoresSafeArea()`.
Auf dem Mac ist der Einzug der Fensterleiste genau der Unterschied
zwischen beiden — die Rechnung lag um diesen Betrag daneben.

Jetzt wird die Bildfläche gemessen statt angenommen, und beide Zahlen
stehen in einem gemeinsamen, benannten Koordinatenraum. Vor allem aber
wird zuerst `AVPlayerLayer.videoRect` gefragt: der Player weiss selbst,
wo Bild steht. Die eigene Rechnung bleibt für den Moment vor dem ersten
Bild und für VLC, das keine solche Ebene hat.

Dazu zwei Worte: „Nachschub" heisst jetzt „Nachladen", und „Blatt" heisst
wieder „Sheet" — das ist ein Fachbegriff und bleibt englisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 06:49:15 +02:00
co-authored by Claude Opus 5
parent f47c6385e3
commit 18958aadbc
17 changed files with 4234 additions and 4190 deletions
+1 -1
View File
@@ -2521,7 +2521,7 @@
"de": {
"stringUnit": {
"state": "translated",
"value" : "Der Nachschub ist hängen geblieben — es fehlen noch Titel."
"value": "Das Nachladen ist hängen geblieben — es fehlen noch Titel."
}
},
"en": {
+1 -1
View File
@@ -141,7 +141,7 @@ extension Comparable {
///
/// Gespeicherte Werte werden beim Lesen geprüft, nicht blind übernommen:
/// eine ältere Fassung mit anderen Grenzen — oder ein von Hand
/// verändertes Voreinstellungsblatt — soll nicht zu einer Kachel führen,
/// verändertes Voreinstellungs-Sheet — soll nicht zu einer Kachel führen,
/// die den halben Bildschirm einnimmt.
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
+1 -1
View File
@@ -14,7 +14,7 @@ struct LibraryFilterSheet: View {
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
///
/// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus —
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery
+4 -4
View File
@@ -17,7 +17,7 @@ struct LibraryView: View {
@State private var state: LoadState<Void> = .loading
@State private var query = MediaQuery()
@State private var page: MediaPage?
/// Ob der letzte Nachschub misslungen ist.
/// Ob der letzte Nachladen misslungen ist.
///
/// Dann steht unten eine Zeile zum Weiterversuchen: von selbst passiert
/// nichts mehr, weil die Kacheln, die das Nachladen auslösen, längst
@@ -171,7 +171,7 @@ struct LibraryView: View {
}
}
/// Der Weg weiter, wenn der Nachschub hängen geblieben ist.
/// Der Weg weiter, wenn das Nachladen hängen geblieben ist.
@ViewBuilder
private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore {
@@ -261,7 +261,7 @@ struct LibraryView: View {
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
// Fünf Einträge vor Schluss: früh genug, dass das Nachgeladene da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.id == item.id }),
@@ -305,7 +305,7 @@ struct LibraryView: View {
// hindeutete. Genau so sieht „es sind nicht alle Filme drin" aus.
//
// Die Seite bleibt also, damit ein zweiter Versuch möglich ist.
// Ein Fehlerblatt mitten im Scrollen wäre trotzdem störend —
// Ein Fehler-Sheet mitten im Scrollen wäre trotzdem störend —
// stattdessen erscheint unten eine Zeile zum Weiterladen.
loadMoreFailed = true
}
+1 -1
View File
@@ -152,7 +152,7 @@ struct LiveTVView: View {
)
)
}
// Ein Blatt statt eines Dialogs: die Antwort des Servers ist
// Ein Sheet statt eines Dialogs: die Antwort des Servers ist
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
// wieder nur, dass es nicht ging.
.sheet(item: $tuneFailure) { failure in
+1 -1
View File
@@ -59,7 +59,7 @@ struct AccountBar: View {
.background(Color.flixySurface)
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
// steht trotzdem da, damit aus dem Popover dort kein
// bildschirmfüllendes Blatt wird.
// bildschirmfüllendes Sheet wird.
.presentationCompactAdaptation(.popover)
}
.confirmationDialog(
+1 -1
View File
@@ -212,7 +212,7 @@ struct RootView: View {
/// Jahr vorgibt.
///
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
/// ausfahrbares Sheet, eine geteilte Ansicht und eine Reihe oben. Sie
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
/// gepflegt werden.
/// Läuft diese App gerade auf einem Mac?
+56 -21
View File
@@ -45,8 +45,19 @@ struct PlayerView: View {
/// 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
/// Wo die Bildfläche im Player liegt — gemessen, nicht angenommen.
///
/// **Der Unterschied zur Fenstergrösse.** Die Bildfläche hat
/// `ignoresSafeArea()`, der Player selbst nicht. Auf dem Mac liegt oben
/// die Fensterleiste als Einzug: die beiden Flächen sind gegeneinander
/// verschoben. Wer den schwarzen Rand aus der Fenstergrösse ausrechnet,
/// rechnet ihn um genau diesen Einzug falsch — und trifft daneben.
@State private var videoFrame: CGRect = .zero
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
private var isMac: Bool { FlixyPlatform.isMac }
/// Der gemeinsame Raum für Messen und Tippen.
private static let playerSpace = "flixy.player"
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
@State private var lastPointerReveal: Date = .distantPast
@@ -64,6 +75,9 @@ struct PlayerView: View {
isActive: playback.presentation == .full
)
.ignoresSafeArea()
.onGeometryChange(for: CGRect.self) {
$0.frame(in: .named(Self.playerSpace))
} action: { videoFrame = $0 }
}
// **Unter** den Bedienelementen, nicht darüber. Als Überzug
@@ -162,7 +176,11 @@ struct PlayerView: View {
// 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) }
// **Ein benannter Raum, kein `.local`.** Gemessen wird die Bildfläche,
// getippt wird auf den Player — zwei verschiedene Ansichten. Nur in
// einem gemeinsamen Raum sind die beiden Zahlen vergleichbar.
.coordinateSpace(.named(Self.playerSpace))
.onTapGesture(coordinateSpace: .named(Self.playerSpace)) { tap(at: $0) }
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
) { _ in
@@ -196,7 +214,7 @@ struct PlayerView: View {
}
// **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.**
//
// Ein Blatt hängt an einem eigenen Darstellungsbaum. Auf dem iPad kam
// Ein Sheet hängt an einem eigenen Darstellungsbaum. Auf dem iPad kam
// die Umgebung des Players dort bisher an, auf dem Mac nicht — und
// `@Environment(PlaybackSettings.self)` ist kein Wert mit
// Ersatzangabe, sondern eine Behauptung: fehlt der Gegenstand, hält
@@ -288,7 +306,7 @@ struct PlayerView: View {
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
// Film. Ein gemeinsames Blatt bedeutete jedes Mal Scrollen an der
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
// anderen Liste vorbei.
if !model.audioTracks.isEmpty {
trackButton(
@@ -679,41 +697,58 @@ struct PlayerView: View {
/// 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.
/// **Zuerst die Ebene fragen.** `AVPlayerLayer.videoRect` ist die Fläche,
/// auf der wirklich Bild steht — vom Player selbst ausgerechnet, aus dem,
/// was gerade ankommt, und einschliesslich dessen, was die Füllart damit
/// macht. Jede eigene Rechnung ist daneben eine Nachbildung, die
/// irgendwann abweicht.
///
/// Ohne Maße die ganze Fläche: dann verhält es sich wie bisher, statt
/// **Dann die eigene Rechnung.** Solange die Ebene noch nichts zeigt —
/// und bei VLC, das keine hat — bleibt das Seitenverhältnis aus den
/// Messwerten. Es wird auf `videoFrame` angewandt, nicht auf die
/// Fenstergrösse: die beiden unterscheiden sich um den Einzug der
/// Fensterleiste, und **das** war der Fehler, wegen dem ein Klick in den
/// schwarzen Rand bisher angehalten hat statt einzublenden.
///
/// Ohne beides die ganze Fläche: dann verhält es sich wie früher, statt
/// aus Unkenntnis überall das Falsche zu tun.
private var videoRect: CGRect {
let ganzes = CGRect(origin: .zero, size: playerSize)
let fläche = videoFrame.isEmpty ? ganzes : videoFrame
guard fläche.width > 0, fläche.height > 0 else { return ganzes }
// Die Ebene liegt in der Bildfläche; ihre Angabe ist relativ zu deren
// Ecke und muss dorthin verschoben werden.
let ausDerEbene = model.surface.videoRect
if ausDerEbene.width > 1, ausDerEbene.height > 1 {
return ausDerEbene.offsetBy(dx: fläche.minX, dy: fläche.minY)
}
guard let breite = model.statistics?.width,
let höhe = model.statistics?.height,
breite > 0, höhe > 0,
playerSize.width > 0, playerSize.height > 0
breite > 0, höhe > 0
else { return ganzes }
let verhältnisBild = CGFloat(breite) / CGFloat(höhe)
let verhältnisFläche = playerSize.width / playerSize.height
let verhältnisFläche = fläche.width / fläche.height
if verhältnisBild > verhältnisFläche {
// Breiter als das Fenster: Balken oben und unten.
let sichtbar = playerSize.width / verhältnisBild
// Breiter als die Fläche: Balken oben und unten.
let sichtbar = fläche.width / verhältnisBild
return CGRect(
x: 0,
y: (playerSize.height - sichtbar) / 2,
width: playerSize.width,
x: fläche.minX,
y: fläche.minY + (fläche.height - sichtbar) / 2,
width: fläche.width,
height: sichtbar
)
}
// Höher als das Fenster: Balken links und rechts.
let sichtbar = playerSize.height * verhältnisBild
// Höher als die Fläche: Balken links und rechts.
let sichtbar = fläche.height * verhältnisBild
return CGRect(
x: (playerSize.width - sichtbar) / 2,
y: 0,
x: fläche.minX + (fläche.width - sichtbar) / 2,
y: fläche.minY,
width: sichtbar,
height: playerSize.height
height: fläche.height
)
}
+2 -2
View File
@@ -4,7 +4,7 @@ import FlixyPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
///
/// Ein Blatt statt eines Dialogs: die Leiter hat je nach Quelle bis zu
/// Ein Sheet statt eines Dialogs: die Leiter hat je nach Quelle bis zu
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
/// was gerade über die Leitung kommt.
@@ -15,7 +15,7 @@ struct QualitySheet: View {
/// **Als Fragezeichen**, nicht als Behauptung.
///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
/// an, wenn der Gegenstand nicht im Baum steht — und ein Blatt hängt an
/// an, wenn der Gegenstand nicht im Baum steht — und ein Sheet hängt an
/// einem eigenen Baum. Genau daran ist der Player eingefroren, und zu
/// sehen war nichts davon. Der Player reicht die Einstellungen jetzt
/// ausdrücklich herein; das Fragezeichen sorgt dafür, dass ein Vergessen
+1 -1
View File
@@ -5,7 +5,7 @@ import FlixyPlayback
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
///
/// Getrennt, weil es zwei verschiedene Fragen sind: die Tonspur wechselt man
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Blatt
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Sheet
/// bedeutete jedes Mal Scrollen an der anderen Liste vorbei.
struct TrackPickerSheet: View {
enum Kind: Identifiable {
+9
View File
@@ -80,6 +80,12 @@ struct VideoSurface: View {
final class PlayerLayerView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
/// Die Fläche, auf der wirklich Bild steht — ohne die schwarzen Ränder.
///
/// Vom Player ausgerechnet, aus dem, was gerade ankommt. Vor dem ersten
/// Bild ist sie leer; wer sie benutzt, muss das prüfen.
var videoRect: CGRect { playerLayer.videoRect }
}
#else
/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene
@@ -87,6 +93,9 @@ final class PlayerLayerView: UIView {
final class PlayerLayerView: NSView {
let playerLayer = AVPlayerLayer()
/// Siehe die UIKit-Fassung oben.
var videoRect: CGRect { playerLayer.videoRect }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
+2 -2
View File
@@ -56,7 +56,7 @@ struct JellyfinSignInView: View {
model = fresh
}
.onDisappear {
// Wird das Blatt weggewischt, läuft sonst die Abfrageschleife
// Wird das Sheet weggewischt, läuft sonst die Abfrageschleife
// noch minutenlang weiter — unsichtbar und für nichts.
model?.cancel()
}
@@ -106,7 +106,7 @@ struct JellyfinSignInView: View {
.submitLabel(.go)
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
// Hier statt beim Öffnen des Blattes: so bekommt das Feld den
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
.task { focusedField = .address }
.padding(FlixySpacing.regular)
@@ -46,14 +46,14 @@ extension View {
#endif
}
/// Ein Blatt, das die halbe Höhe einnimmt.
/// Ein Sheet, das die halbe Höhe einnimmt.
///
/// Auf dem Mac ist ein Blatt ein Fenster: dort zählt keine Rastung,
/// Auf dem Mac ist ein Sheet ein Fenster: dort zählt keine Rastung,
/// sondern eine vernünftige Mindestgröße.
public func flixyMediumSheet() -> some View {
// **Zur Laufzeit, nicht beim Übersetzen.** Der `#else`-Zweig für
// macOS griff nie: auf dem Mac läuft der iOS-Bau, und damit bekam ein
// grosses Fenster eine Blattkante in iPhone-Höhe — ein schmaler
// grosses Fenster eine Sheet-Kante in iPhone-Höhe — ein schmaler
// Streifen, der unten im Nichts klebt.
presentationDetents(FlixyPlatform.isMac ? [.large] : [.medium])
}
@@ -220,7 +220,7 @@ extension JellyfinBackend: LiveTVBackend {
channelID: channelID,
title: item.name ?? "—",
// `EpisodeTitle` und nicht `Name`: hier stand der Reihentitel ein
// zweites Mal, und im Blatt hätte derselbe Satz zweimal
// zweites Mal, und im Sheet hätte derselbe Satz zweimal
// untereinander gestanden.
subtitle: item.episodeTitle,
summary: item.overview,
@@ -60,7 +60,7 @@ public struct TranscodeSettings: Sendable, Equatable {
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
/// liegt, erfindet der Server.
///
/// Nur bei `automatic` gesetzt. Wer im Blatt ausdrücklich „720p · 3 Mbps"
/// Nur bei `automatic` gesetzt. Wer im Sheet ausdrücklich „720p · 3 Mbps"
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
/// unter unverändertem Etikett.
public let prefersAdaptive: Bool
@@ -94,7 +94,7 @@ public actor PlexAuthenticator {
do {
try await clock.sleep(for: pollInterval)
} catch {
// Abbruch durch die Oberfläche, etwa weil das Anmeldeblatt
// Abbruch durch die Oberfläche, etwa weil das Anmelde-Sheet
// geschlossen wurde. Kein Fehlerfall.
throw .transport(description: "Anmeldung abgebrochen")
}
@@ -77,14 +77,14 @@ struct LibraryQueryTests {
@Suite("Seitenweises Nachladen")
struct PlexPageTests {
@Test("Eine volle Seite kündigt Nachschub an")
@Test("Eine volle Seite kündigt weitere Titel an")
func fullPageHasMore() {
let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250)
#expect(page.hasMore)
#expect(page.nextOffset == 60)
}
@Test("Die letzte Seite kündigt keinen Nachschub an")
@Test("Die letzte Seite kündigt keine weiteren Titel an")
func lastPageHasNoMore() {
// Genau die Bedingung, die sonst zu einer Endlosschleife führt: die
// Ansicht lädt nach, bekommt nichts, lädt wieder nach.