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:
co-authored by
Claude Opus 5
parent
f47c6385e3
commit
18958aadbc
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user